Overall Statistics
Total Orders
1050
Average Win
0.95%
Average Loss
-0.96%
Compounding Annual Return
-39.429%
Drawdown
7.000%
Expectancy
-0.008
Start Equity
100000
End Equity
96402
Net Profit
-3.598%
Sharpe Ratio
-1.221
Sortino Ratio
-2.54
Probabilistic Sharpe Ratio
25.220%
Loss Rate
50%
Win Rate
50%
Profit-Loss Ratio
0.98
Alpha
0.229
Beta
-0.755
Annual Standard Deviation
0.263
Annual Variance
0.069
Information Ratio
-3.114
Tracking Error
0.337
Treynor Ratio
0.424
Total Fees
$1248.00
Estimated Strategy Capacity
$2500000.00
Lowest Capacity Asset
SPXW 324LN4BSGZPM6|SPX 31
Portfolio Turnover
15.86%
#region imports
from AlgorithmImports import *
#endregion

from Initialization import SetupBaseStructure
from Alpha.Utils import Scanner, Order, Stats
from Tools import ContractUtils, Logger, Underlying
from Strategy import Leg, Position, OrderType, WorkingOrder

"""
NOTE: We can't use multiple inheritance in Python because this is a managed class. We will use composition instead so in
      order to call the methods of SetupBaseStructure we'll call then using self.setup.methodName().
----------------------------------------------------------------------------------------------------------------------------------------
The base class for all the alpha models. It is used to setup the base structure of the algorithm and to run the strategies.
This class has some configuration capabilities that can be used to setup the strategies more easily by just changing the
configuration parameters.

Here are the default values for the configuration parameters:

    scheduleStartTime: time(9, 30, 0)
    scheduleStopTime: None
    scheduleFrequency: timedelta(minutes = 5)
    maxActivePositions: 1
    dte: 0
    dteWindow: 0

----------------------------------------------------------------------------------------------------------------------------------------
The workflow of the algorithm is the following:

    `Update` method gets called every minute
        - If the market is closed, the algorithm exits
        - If algorithm is warming up, the algorithm exits
        - The Scanner class is used to filter the option chain
            - If the chain is empty, the algorithm exits
        - The CreateInsights method is called
            - Inside the GetOrder method is called
"""


class Base(AlphaModel):
    # Internal counter for all the orders
    orderCount = 0

    DEFAULT_PARAMETERS = {
        # The start time at which the algorithm will start scheduling the strategy execution
        # (to open new positions). No positions will be opened before this time
        "scheduleStartTime": time(9, 30, 0),
        # The stop time at which the algorithm will look to open a new position.
        "scheduleStopTime": None,  # time(13, 0, 0),
        # Periodic interval with which the algorithm will check to open new positions
        "scheduleFrequency": timedelta(minutes=5),
        # Minimum time distance between opening two consecutive trades
        "minimumTradeScheduleDistance": timedelta(days=1),
        # If True, the order is not placed if the legs are already part of an existing position.
        "checkForDuplicatePositions": True,
        # Maximum number of open positions at any given time
        "maxActivePositions": 1,
        # Maximum quantity used to scale each position. If the target premium cannot be reached within this
        # quantity (i.e. premium received is too low), the position is not going to be opened
        "maxOrderQuantity": 1,
        # If True, the order is submitted as long as it does not exceed the maxOrderQuantity.
        "validateQuantity": True,
        # Days to Expiration
        "dte": 0,
        # The size of the window used to filter the option chain: options expiring in the range [dte-dteWindow, dte] will be selected
        "dteWindow": 0,
        # DTE Threshold. This is ignored if self.dte < self.dteThreshold
        "dteThreshold": 21,
        # Controls whether to use the furthest (True) or the earliest (False) expiration date when multiple expirations are available in the chain
        "useFurthestExpiry": True,
        # Controls whether to consider the DTE of the last closed position when opening a new one:
        # If True, the Expiry date of the new position is selected such that the open DTE is the nearest to the DTE of the closed position
        "dynamicDTESelection": False,
        # Coarse filter for the Universe selection. It selects nStrikes on both sides of the ATM strike for each available expiration
        "nStrikesLeft": 200,   # 200 SPX @ 3820 & 3910C w delta @ 1.95 => 90/5 = 18
        "nStrikesRight": 200,   # 200
        # Controls what happens when an open position reaches/crosses the dteThreshold ( -> DTE(openPosition) <= dteThreshold)
        # - If True, the position is closed as soon as the dteThreshold is reached, regardless of whether the position is profitable or not
        # - If False, once the dteThreshold is reached, the position is closed as soon as it is profitable
        "forceDteThreshold": False,
        # DIT Threshold. This is ignored if self.dte < self.ditThreshold
        "ditThreshold": None,
        "hardDitThreshold": None,
        # Controls what happens when an open position reaches/crosses the ditThreshold ( -> DIT(openPosition) >= ditThreshold)
        # - If True, the position is closed as soon as the ditThreshold is reached, regardless of whether the position is profitable or not
        # - If False, once the ditThreshold is reached, the position is closed as soon as it is profitable
        # - If self.hardDitThreashold is set, the position is closed once the hardDitThreashold is
        # crossed, regardless of whether forceDitThreshold is True or False
        "forceDitThreshold": False,
        # Slippage used to set Limit orders
        "slippage": 0.0,
        # Used when validateBidAskSpread = True. if the ratio between the bid-ask spread and the
        # mid-price is higher than this parameter, the order is not executed
        "bidAskSpreadRatio": 0.3,
        # If True, the order mid-price is validated to make sure the Bid-Ask spread is not too wide.
        #  - The order is not submitted if the ratio between Bid-Ask spread of the entire order and its mid-price is more than self.bidAskSpreadRatio
        "validateBidAskSpread": False,
        # Control whether to allow multiple positions to be opened for the same Expiration date
        "allowMultipleEntriesPerExpiry": False,
        # Controls whether to include details on each leg (open/close fill price and descriptive statistics about mid-price, Greeks, and IV)
        "includeLegDetails": False,
        # The frequency (in minutes) with which the leg details are updated (used only if includeLegDetails = True)
        "legDatailsUpdateFrequency": 30,
        # Controls whether to track the details on each leg across the life of the trade
        "trackLegDetails": False,
        # Controls which greeks are included in the output log
        # "greeksIncluded": ["Delta", "Gamma", "Vega", "Theta", "Rho", "Vomma", "Elasticity"],
        "greeksIncluded": [],
        # Controls whether to compute the greeks for the strategy. If True, the greeks will be computed and stored in the contract under BSMGreeks.
        "computeGreeks": False,
        # The time (on expiration day) at which any position that is still open will closed
        "marketCloseCutoffTime": time(15, 45, 0),
        # Limit Order Management
        "useLimitOrders": True,
        # Adjustment factor applied to the Mid-Price to set the Limit Order:
        #  - Credit Strategy:
        #      Adj = 0.3 --> sets the Limit Order price 30% higher than the current Mid-Price
        #  - Debit Strategy:
        #      Adj = -0.2 --> sets the Limit Order price 20% lower than the current Mid-Price
        "limitOrderRelativePriceAdjustment": 0,
        # Set expiration for Limit orders. This tells us how much time a limit order will stay in pending mode before it gets a fill.
        "limitOrderExpiration": timedelta(hours=8),
        # Alternative method to set the absolute price (per contract) of the Limit Order. This method is used if a number is specified
        # Unless you know that your price target can get a fill, it is advisable to use a relative adjustment or you may never get your order filled
        #  - Credit Strategy:
        #      AbsolutePrice = 1.5 --> sets the Limit Order price at exactly 1.5$
        #  - Debit Strategy:
        #      AbsolutePrice = -2.3 --> sets the Limit Order price at exactly -2.3$
        "limitOrderAbsolutePrice": None,
        # Target <credit|debit> premium amount: used to determine the number of contracts needed to reach the desired target amount
        #  - targetPremiumPct --> target premium is expressed as a percentage of the total Portfolio Net Liq (0 < targetPremiumPct < 1)
        #  - targetPremium --> target premium is a fixed dollar amount
        # If both are specified, targetPremiumPct takes precedence. If none of them are specified,
        # the number of contracts specified by the maxOrderQuantity parameter is used.
        "targetPremiumPct": None,
        # You can't have one without the other in this case below.
        # Minimum premium accepted for opening a new position. Setting this to None disables it.
        "minPremium": None,
        # Maximum premium accepted for opening a new position. Setting this to None disables it.
        "maxPremium": None,
        "targetPremium": None,
        # Defines how the profit target is calculated. Valid options are (case insensitive):
        # - Premium: the profit target is a percentage of the premium paid/received.
        # - Theta: the profit target is calculated based on the theta value of the position evaluated
        # at self.thetaProfitDays from the time of entering the trade
        # - TReg: the profit target is calculated as a percentage of the TReg (MaxLoss + openPremium)
        # - Margin: the profit target is calculted as a percentage of the margin requirement (calculated based on
        # self.portfolioMarginStress percentage upside/downside movement of the underlying)
        "profitTargetMethod": "Premium",
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        "profitTarget": 0.6,
        # Number of days into the future at which the theta of the position is calculated. Used if profitTargetMethod = "Theta"
        "thetaProfitDays": None,
        # Delta and Wing size used for Naked Put/Call and Spreads
        "delta": 10,
        "wingSize": 10,
        # Put/Call delta for Iron Condor
        "putDelta": 10,
        "callDelta": 10,
        # Net delta for Straddle, Iron Fly and Butterfly (using ATM strike if netDelta = None)
        "netDelta": None,
        # Put/Call Wing size for Iron Condor, Iron Fly
        "putWingSize": 10,
        "callWingSize": 10,
        # Butterfly specific parameters
        "butteflyType": None,
        "butterflyLeftWingSize": 10,
        "butterflyRightWingSize": 10,
    }

    def __init__(self, context):
        self.context = context
        # Set default name (use the class name)
        self.name = type(self).__name__
        # Set the Strategy Name (optional)
        self.nameTag = self.name
        # Set the logger
        self.logger = Logger(context, className=type(self).__name__, logLevel=context.logLevel)
        self.order = Order(context, self)
        # This adds all the parameters to the class. We can also access them via self.parameter("parameterName")
        self.context.structure.AddConfiguration(parent=self, **self.getMergedParameters())
        # Initialize the contract utils
        self.contractUtils = ContractUtils(context)
        # Initialize the stats dictionary
        # This will hold any details related to the underlying.
        # For example, the underlying price at the time of opening of day
        self.stats = Stats()
        self.logger.debug(f'{self.name} -> __init__')

    @staticmethod
    def getNextOrderId():
        Base.orderCount += 1
        return Base.orderCount

    @classmethod
    def getMergedParameters(cls):
        # Merge the DEFAULT_PARAMETERS from both classes
        return {**cls.DEFAULT_PARAMETERS, **getattr(cls, "PARAMETERS", {})}

    @classmethod
    def parameter(cls, key, default=None):
        return cls.getMergedParameters().get(key, default)

    def update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
        insights = []
        # Start the timer
        self.context.executionTimer.start('Alpha.Base -> Update')
        self.logger.debug(f'{self.name} -> update -> start')
        self.logger.debug(f'Is Warming Up: {self.context.IsWarmingUp}')
        self.logger.debug(f'Is Market Open: {self.context.IsMarketOpen(self.underlyingSymbol)}')
        self.logger.debug(f'Time: {self.context.Time}')
        # Exit if the algorithm is warming up or the market is closed (avoid processing orders on the last minute as these will be executed the following day)
        if self.context.IsWarmingUp or\
           not self.context.IsMarketOpen(self.underlyingSymbol) or\
           self.context.Time.time() >= time(16, 0, 0):
            return insights
        
        self.logger.debug(f'Did Alpha UPDATE after warmup?!?')
        # This thing just passes the data to the performance tool so we can keep track of all 
        # symbols. This should not be needed if the culprit of the slonwess of backtesting is sorted.
        self.context.performance.OnUpdate(data)

        # Update the stats dictionary
        self.syncStats()

        # Check if the workingOrders are still OK to execute
        self.context.structure.checkOpenPositions()

        # Run the strategies to open new positions
        filteredChain, lastClosedOrderTag = Scanner(self.context, self).Call(data)

        self.logger.debug(f'Did Alpha SCAN')
        self.logger.debug(f'Last Closed Order Tag: {lastClosedOrderTag}')
        if filteredChain is not None:
            if self.stats.hasOptions == False:
                self.logger.info(f"Found options {self.context.Time.strftime('%A, %Y-%m-%d %H:%M')}")
            self.stats.hasOptions = True
            insights = self.CreateInsights(filteredChain, lastClosedOrderTag, data)
        elif self.stats.hasOptions is None and self.context.Time.time() >= time(9, 35, 0):
            self.stats.hasOptions = False
            self.logger.info(f"No options data for {self.context.Time.strftime('%A, %Y-%m-%d %H:%M')}")
            self.logger.debug(f"NOTE: Why could this happen? A: The filtering of the chain caused no contracts to be returned. Make sure to make a check on this.")

        # Stop the timer
        self.context.executionTimer.stop('Alpha.Base -> Update')
        return Insight.Group(insights)

    # Get the order with extra filters applied by the strategy
    def GetOrder(self, chain):
        raise NotImplementedError("GetOrder() not implemented")

    # Previous method CreateOptionPosition.py#OpenPosition
    def CreateInsights(self, chain, lastClosedOrderTag=None, data = Slice) -> List[Insight]:
        insights = []
        # Call the getOrder method of the class implementing OptionStrategy
        order = self.getOrder(chain, data)
        # Execute the order
        # Exit if there is no order to process
        if order is None:
            return insights

        # Start the timer
        self.context.executionTimer.start('Alpha.Base -> CreateInsights')

        # Get the context
        context = self.context

        order = [order] if not isinstance(order, list) else order
        for o in order:
            self.logger.debug(f"CreateInsights -> strategyId: {o['strategyId']}, strikes: {o['strikes']}")

        for single_order in order:
            position, workingOrder = self.buildOrderPosition(single_order, lastClosedOrderTag)
            self.logger.debug(f"CreateInsights -> position: {position}")
            self.logger.debug(f"CreateInsights -> workingOrder: {workingOrder}")
            if position is None:
                continue

            # if self.hasDuplicateLegs(single_order):
            #     self.logger.debug(f"CreateInsights -> Duplicate legs found in order: {single_order}")
            #     continue

            orderId = position.orderId
            orderTag = position.orderTag
            insights.extend(workingOrder.insights)

            # Add this position to the global dictionary
            context.allPositions[orderId] = position
            context.openPositions[orderTag] = orderId

            # Keep track of all the working orders
            context.workingOrders[orderTag] = {}

            # Map each contract to the openPosition dictionary (key: expiryStr)
            context.workingOrders[orderTag] = workingOrder

        self.logger.debug(f"CreateInsights -> insights: {insights}")
        # Stop the timer
        self.context.executionTimer.stop('Alpha.Base -> CreateInsights')
        return insights

    def buildOrderPosition(self, order, lastClosedOrderTag=None):
        # Get the context
        context = self.context

        # Get the list of contracts
        contracts = order["contracts"]
        self.logger.debug(f"buildOrderPosition -> contracts: {len(contracts)}")
        # Exit if there are no contracts
        if (len(contracts) == 0):
            return [None, None]

        useLimitOrders = self.useLimitOrders
        useMarketOrders = not useLimitOrders

        # Current timestamp
        currentDttm = self.context.Time

        strategyId = order["strategyId"]
        contractSide = order["contractSide"]
        # midPrices = order["midPrices"]
        strikes = order["strikes"]
        # IVs = order["IV"]
        expiry = order["expiry"]
        targetPremium = order["targetPremium"]
        maxOrderQuantity = order["maxOrderQuantity"]
        orderQuantity = order["orderQuantity"]
        bidAskSpread = order["bidAskSpread"]
        orderMidPrice = order["orderMidPrice"]
        limitOrderPrice = order["limitOrderPrice"]
        maxLoss = order["maxLoss"]
        targetProfit = order.get("targetProfit", None)

        # Expiry String
        expiryStr = expiry.strftime("%Y-%m-%d")

        self.logger.debug(f"buildOrderPosition -> expiry: {expiry}, expiryStr: {expiryStr}")

        # Validate the order prior to submit
        if (  # We have a minimum order quantity
                orderQuantity == 0
                # The sign of orderMidPrice must be consistent with whether this is a credit strategy (+1) or debit strategy (-1)
                or np.sign(orderMidPrice) != 2 * int(order["creditStrategy"]) - 1
                # Exit if the order quantity exceeds the maxOrderQuantity
                or (self.validateQuantity and orderQuantity > maxOrderQuantity)
                # Make sure the bid-ask spread is not too wide before opening the position.
                # Only for Market orders. In case of limit orders, this validation is done at the time of execution of the Limit order
                or (useMarketOrders and self.validateBidAskSpread
                    and abs(bidAskSpread) >
                    self.bidAskSpreadRatio * abs(orderMidPrice))):
            return [None, None]

        self.logger.debug(f"buildOrderPosition -> orderMidPrice: {orderMidPrice}, orderQuantity: {orderQuantity}, maxOrderQuantity: {maxOrderQuantity}")

        # Get the current price of the underlying
        underlyingPrice = self.contractUtils.getUnderlyingLastPrice(contracts[0])

        # Get the Order Id and add it to the order dictionary
        orderId = self.getNextOrderId()
        # Create unique Tag to keep track of the order when the fill occurs
        orderTag = f"{strategyId}-{orderId}"

        strategyLegs = []
        self.logger.debug(f"buildOrderPosition -> strategyLegs: {strategyLegs}")
        for contract in contracts:
            key = order["contractSideDesc"][contract.Symbol]
            leg = Leg(
                key=key,
                strike=strikes[key],
                expiry=order["contractExpiry"][key],
                contractSide=contractSide[contract.Symbol],
                symbol=contract.Symbol,
                contract=contract,
            )

            strategyLegs.append(leg)

        position = Position(
            orderId=orderId,
            orderTag=orderTag,
            strategy=self,
            strategyTag=self.nameTag,
            strategyId=strategyId,
            legs=strategyLegs,
            expiry=expiry,
            expiryStr=expiryStr,
            targetProfit=targetProfit,
            linkedOrderTag=lastClosedOrderTag,
            contractSide=contractSide,
            openDttm=currentDttm,
            openDt=currentDttm.strftime("%Y-%m-%d"),
            openDTE=(expiry.date() - currentDttm.date()).days,
            limitOrder=useLimitOrders,
            targetPremium=targetPremium,
            orderQuantity=orderQuantity,
            maxOrderQuantity=maxOrderQuantity,
            openOrderMidPrice=orderMidPrice,
            openOrderMidPriceMin=orderMidPrice,
            openOrderMidPriceMax=orderMidPrice,
            openOrderBidAskSpread=bidAskSpread,
            openOrderLimitPrice=limitOrderPrice,
            # underlyingPriceAtOrderOpen=underlyingPrice,
            underlyingPriceAtOpen=underlyingPrice,
            openOrder=OrderType(
                limitOrderExpiryDttm=context.Time + self.limitOrderExpiration,
                midPrice=orderMidPrice,
                limitOrderPrice=limitOrderPrice,
                bidAskSpread=bidAskSpread,
                maxLoss=maxLoss
            )
        )

        self.logger.debug(f"buildOrderPosition -> position: {position}")

        # Create combo orders by using the provided method instead of always calling MarketOrder.
        insights = []

        # Create the orders
        for contract in contracts:
            # Subscribe to the option contract data feed
            if contract.Symbol not in context.optionContractsSubscriptions:
                context.AddOptionContract(contract.Symbol, context.timeResolution)
                context.optionContractsSubscriptions.append(contract.Symbol)

            # Get the contract side (Long/Short)
            orderSide = contractSide[contract.Symbol]
            insight = Insight.Price(
                contract.Symbol,
                position.openOrder.limitOrderExpiryDttm,
                InsightDirection.Down if orderSide == -1 else InsightDirection.Up
            )
            insights.append(insight)

        self.logger.debug(f"buildOrderPosition -> insights: {insights}")

        # Map each contract to the openPosition dictionary (key: expiryStr)
        workingOrder = WorkingOrder(
            positionKey=orderId,
            insights=insights,
            limitOrderPrice=limitOrderPrice,
            orderId=orderId,
            strategy=self,
            strategyTag=self.nameTag,
            useLimitOrder=useLimitOrders,
            orderType="open",
            fills=0
        )

        self.logger.debug(f"buildOrderPosition -> workingOrder: {workingOrder}")

        return [position, workingOrder]

    def hasDuplicateLegs(self, order):
        # Check if checkForDuplicatePositions is enabled
        if not self.checkForDuplicatePositions:
            return False

        # Get the context
        context = self.context

        # Get the list of contracts
        contracts = order["contracts"]

        openPositions = context.openPositions
        """
        workingOrders = context.workingOrders

        # Get a list of orderIds from openPositions and workingOrders
        orderIds = list(openPositions.keys()) + [workingOrder.orderId for workingOrder in workingOrders.values()]

        # Iterate through the list of orderIds
        for orderId in orderIds:
        """

        # Iterate through open positions
        for orderTag, orderId in list(openPositions.items()):
            position = context.allPositions[orderId]

            # Check if the expiry matches
            if position.expiryStr != order["expiry"].strftime("%Y-%m-%d"):
                continue

            # Check if the strategy matches (if allowMultipleEntriesPerExpiry is False)
            if not self.allowMultipleEntriesPerExpiry and position.strategyId == order["strategyId"]:
                return True

            # Compare legs
            position_legs = set((leg.strike, leg.contractSide) for leg in position.legs)
            order_legs = set((contract.Strike, order["contractSide"][contract.Symbol]) for contract in contracts)

            if position_legs == order_legs:
                return True

        return False

    """
    This method is called every minute to update the stats dictionary.
    """
    def syncStats(self):
        # Get the current day
        currentDay = self.context.Time.date()
        # Update the underlyingPriceAtOpen to be set at the start of each day
        underlying = Underlying(self.context, self.underlyingSymbol)
        if currentDay != self.stats.currentDay:
            self.logger.trace(f"Previous day: {self.stats.currentDay} data {self.stats.underlyingPriceAtOpen}, {self.stats.highOfTheDay}, {self.stats.lowOfTheDay}")
            self.stats.underlyingPriceAtOpen = underlying.Price()
            # Update the high/low of the day
            self.stats.highOfTheDay = underlying.Close()
            self.stats.lowOfTheDay = underlying.Close()
            # Add a dictionary to keep track of whether the price has touched the EMAs
            self.stats.touchedEMAs = {}
            self.logger.debug(f"Updating stats for {currentDay} Open: {self.stats.underlyingPriceAtOpen}, High: {self.stats.highOfTheDay}, Low: {self.stats.lowOfTheDay}")
            self.stats.currentDay = currentDay
            self.stats.hasOptions = None

        # This is like poor mans consolidator
        frequency = 5 # minutes
        # Continue the processing only if we are at the specified schedule
        if self.context.Time.minute % frequency != 0:
            return None

        # This should add the data for the underlying symbol chart.
        self.context.charting.updateCharts(symbol = self.underlyingSymbol)

        # Update the high/low of the day
        self.stats.highOfTheDay = max(self.stats.highOfTheDay, underlying.Close())
        self.stats.lowOfTheDay = min(self.stats.lowOfTheDay, underlying.Close())

    # The method will be called each time a consolidator is receiving data. We have a default one of 5 minutes
    # so if we need something to happen every 5 minutes this can be used for that.
    def dataConsolidated(self, sender, consolidated):
        pass
        
    def OnSecuritiesChanged(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
        # Security additions and removals are pushed here.
        # This can be used for setting up algorithm state.
        # changes.AddedSecurities
        # changes.RemovedSecurities
        pass


#region imports
from AlgorithmImports import *
#endregion

from .Base import Base

class CCModel(Base):
    PARAMETERS = {
        # The start time at which the algorithm will start scheduling the strategy execution (to open new positions). No positions will be opened before this time
        "scheduleStartTime": time(9, 30, 0),
        # The stop time at which the algorithm will look to open a new position.
        "scheduleStopTime": time(16, 0, 0),
        # Periodic interval with which the algorithm will check to open new positions
        "scheduleFrequency": timedelta(minutes = 5),
        # Maximum number of open positions at any given time
        "maxActivePositions": 1,
        # Control whether to allow multiple positions to be opened for the same Expiration date
        "allowMultipleEntriesPerExpiry": True,
        # Minimum time distance between opening two consecutive trades
        "minimumTradeScheduleDistance": timedelta(minutes=10),
        # Days to Expiration
        "dte": 14,
        # The size of the window used to filter the option chain: options expiring in the range [dte-dteWindow, dte] will be selected
        "dteWindow": 14,
        "useLimitOrders": True,
        "limitOrderRelativePriceAdjustment": 0.2,
        # Alternative method to set the absolute price (per contract) of the Limit Order. This method is used if a number is specified
        "limitOrderAbsolutePrice": 0.30,
        "limitOrderExpiration": timedelta(minutes=15),
        # Coarse filter for the Universe selection. It selects nStrikes on both sides of the ATM
        # strike for each available expiration
        # Example: 200 SPX @ 3820 & 3910C w delta @ 1.95 => 90/5 = 18
        "nStrikesLeft": 35,
        "nStrikesRight": 35,
        # TODO fix this and set it based on buying power.
        "maxOrderQuantity": 25,
        "targetPremiumPct": 0.015,
        # Minimum premium accepted for opening a new position. Setting this to None disables it.
        "minPremium": 0.25,
        # Maximum premium accepted for opening a new position. Setting this to None disables it.
        "maxPremium": 0.8,
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        "profitTarget": 0.4,
        "bidAskSpreadRatio": 0.4,
        "validateBidAskSpread": True,
        "marketCloseCutoffTime": None, #time(15, 45, 0),
        # Put/Call Wing size for Iron Condor, Iron Fly
        # "targetPremium": 500,
    }

    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
        # You can change the name here
        self.name = "CCModel"
        self.nameTag = "CCModel"
        self.ticker = "TSLA"
        self.context.structure.AddUnderlying(self, self.ticker)

    def getOrder(self, chain, data):
        if data.ContainsKey(self.underlyingSymbol):
            self.logger.debug(f"CCModel -> getOrder: Data contains key {self.underlyingSymbol}")
            # Based on maxActivePositions set to 1. We should already check if there is an open position or
            # working order. If there is, then this will not even run.
            call =  self.order.getNakedOrder(
                chain,
                'call',
                fromPrice = self.minPremium,
                toPrice = self.maxPremium,
                sell=True
            )
            self.logger.debug(f"CCModel -> getOrder: Call: {call}")
            if call is not None:
                return [call]
            else:
                return None
        else:
            return None


#region imports
from AlgorithmImports import *
#endregion

from .Base import Base
from Data.GoogleSheetsData import GoogleSheetsData

class FPLModel(Base):
    PARAMETERS = {
        # The start time at which the algorithm will start scheduling the strategy execution (to open new positions). No positions will be opened before this time
        "scheduleStartTime": time(9, 20, 0),
        # The stop time at which the algorithm will look to open a new position.
        "scheduleStopTime": None, # time(13, 0, 0),
        # Periodic interval with which the algorithm will check to open new positions
        "scheduleFrequency": timedelta(minutes = 5),
        # Maximum number of open positions at any given time
        "maxActivePositions": 2,
        # Days to Expiration
        "dte": 0,
        # The size of the window used to filter the option chain: options expiring in the range [dte-dteWindow, dte] will be selected
        "dteWindow": 0,
        "useLimitOrders": True,
        "limitOrderRelativePriceAdjustment": 0.2,
        "limitOrderAbsolutePrice": 0.5,
        "limitOrderExpiration": timedelta(hours=1),
        # Coarse filter for the Universe selection. It selects nStrikes on both sides of the ATM
        # strike for each available expiration
        # Example: 200 SPX @ 3820 & 3910C w delta @ 1.95 => 90/5 = 18
        "nStrikesLeft": 18,
        "nStrikesRight": 18,
        "maxOrderQuantity": 40,
        # Minimum premium accepted for opening a new position. Setting this to None disables it.
        "minPremium": 0.50,
        # Maximum premium accepted for opening a new position. Setting this to None disables it.
        "maxPremium": 1.5,
        "profitTarget": 0.5,
        "bidAskSpreadRatio": 0.4,
        "validateBidAskSpread": True,
        "marketCloseCutoffTime": None, #time(15, 45, 0),
        # "targetPremium": 500,
    }

    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
        # You can change the name here
        self.name = "FPLModel"
        self.nameTag = "FPL"
        self.ticker = "SPX"
        self.context.structure.AddUnderlying(self, self.ticker)
        self.customSymbol = self.context.AddData(GoogleSheetsData, "SPXT", Resolution.Minute).Symbol

    def getOrder(self, chain, data):
        if data.ContainsKey(self.customSymbol):
            self.logger.info(f'L: just got a new trade!! {data[self.customSymbol]}')
            print(f'P: just got a new trade!! {data[self.customSymbol]}')
            trade_instructions = data[self.customSymbol]
            tradeType = trade_instructions.Type
            condor = False
            self.logger.info(f'L: instructions: {trade_instructions}')
            print(f'P: instructions: {trade_instructions}')

            if tradeType == 'Call Credit Spreads':
                action = 'call'
                strike = trade_instructions.CallStrike 
            elif tradeType == 'Put Credit Spreads':
                action = 'put'
                strike = trade_instructions.PutStrike
            elif tradeType == 'Iron Condor':
                callStrike = trade_instructions.CallStrike 
                putStrike = trade_instructions.PutStrike
                condor = True
            else:
                return None

            if condor:
                return self.order.getIronCondorOrder(
                    chain, 
                    callStrike = callStrike, 
                    putStrike = putStrike, 
                    callWingSize = 5, 
                    putWingSize = 5
                )
            else:
                return self.order.getSpreadOrder(
                    chain,
                    action,
                    strike=strike,
                    wingSize=5,
                    sell=True
                )
        else:
            return None
        # if not chain.ContainsKey('SPXTRADES'):
        #     return []

        # customTrades = chain['SPXTRADES']

        # if customTrades is None:
        #     return []

        # # Check if the current time is past the instructed time
        # if self.context.Time < customTrades.Time:
        #     return []

        # # Use the customTrades data to generate insights
        # tradeType = customTrades.Type
        # call_strike = customTrades.CallStrike
        # put_strike = customTrades.PutStrike
        # minimum_premium = customTrades.MinimumPremium
        # self.Log(f'{data.EndTime}: Close: {data.Close}')
        # self.Plot(self.custom_data_symbol, 'Price', data.Close)

        #  strike = self.context.underlyingPrice() + self.parameters["distance"]
        
#region imports
from AlgorithmImports import *
#endregion

from .Base import Base

class SPXButterfly(Base):
    PARAMETERS = {
        # The start time at which the algorithm will start scheduling the strategy execution (to open new positions). No positions will be opened before this time
        "scheduleStartTime": time(9, 30, 0),
        # The stop time at which the algorithm will look to open a new position.
        "scheduleStopTime": time(16, 0, 0),
        # Periodic interval with which the algorithm will check to open new positions
        "scheduleFrequency": timedelta(minutes = 15),
        # Maximum number of open positions at any given time
        "maxActivePositions": 30,
        # Control whether to allow multiple positions to be opened for the same Expiration date
        "allowMultipleEntriesPerExpiry": True,
        # Minimum time distance between opening two consecutive trades
        "minimumTradeScheduleDistance": timedelta(minutes=10),
        # Days to Expiration
        "dte": 0,
        # The size of the window used to filter the option chain: options expiring in the range [dte-dteWindow, dte] will be selected
        "dteWindow": 0,
        "useLimitOrders": True,
        "limitOrderRelativePriceAdjustment": 0.2,
        # Alternative method to set the absolute price (per contract) of the Limit Order. This method is used if a number is specified
        "limitOrderExpiration": timedelta(minutes=20),
        # Coarse filter for the Universe selection. It selects nStrikes on both sides of the ATM
        # strike for each available expiration
        # Example: 200 SPX @ 3820 & 3910C w delta @ 1.95 => 90/5 = 18
        "nStrikesLeft": 18,
        "nStrikesRight": 18,
        # TODO fix this and set it based on buying power.
        "maxOrderQuantity": 1000,
        "targetPremiumPct": 0.015,
        # Minimum premium accepted for opening a new position. Setting this to None disables it.
        "minPremium": None,
        # Maximum premium accepted for opening a new position. Setting this to None disables it.
        "maxPremium": None,
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        # "profitTarget": 1.0,
        "bidAskSpreadRatio": 0.4,
        "validateBidAskSpread": True,
        "marketCloseCutoffTime": time(16, 10, 0),
        # Put/Call Wing size for Iron Condor, Iron Fly
        "butterflyLeftWingSize": 35,
        "butterflyRightWingSize": 35,
        # "targetPremium": 500,
    }

    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
        # You can change the name here
        self.name = "SPXButterfly"
        self.nameTag = "SPXButterfly"
        self.ticker = "SPX"
        self.context.structure.AddUnderlying(self, self.ticker)

    def getOrder(self, chain, data):
        # Open trades at 13:00
        if data.ContainsKey(self.underlyingSymbol):
            trade_times = [time(9, 45, 0)]
            current_time = self.context.Time.time()
            if current_time not in trade_times:
                return None
            fly =  self.order.getIronFlyOrder(
                chain,
                callWingSize=self.butterflyLeftWingSize,
                putWingSize=self.butterflyRightWingSize,
                sell=True
            )
            if fly is not None:
                return fly
            else:
                return None
        else:
            return None

        

#region imports
from AlgorithmImports import *
#endregion

from .Base import Base

class SPXCondor(Base):
    PARAMETERS = {
        # The start time at which the algorithm will start scheduling the strategy execution (to open new positions). No positions will be opened before this time
        "scheduleStartTime": time(9, 30, 0),
        # The stop time at which the algorithm will look to open a new position.
        "scheduleStopTime": time(16, 0, 0),
        # Periodic interval with which the algorithm will check to open new positions
        "scheduleFrequency": timedelta(minutes = 15),
        # Maximum number of open positions at any given time
        "maxActivePositions": 30,
        # Control whether to allow multiple positions to be opened for the same Expiration date
        "allowMultipleEntriesPerExpiry": True,
        # Minimum time distance between opening two consecutive trades
        "minimumTradeScheduleDistance": timedelta(minutes=10),
        # Days to Expiration
        "dte": 0,
        # The size of the window used to filter the option chain: options expiring in the range [dte-dteWindow, dte] will be selected
        "dteWindow": 0,
        "useLimitOrders": True,
        "limitOrderRelativePriceAdjustment": 0.2,
        # Alternative method to set the absolute price (per contract) of the Limit Order. This method is used if a number is specified
        "limitOrderAbsolutePrice": 0.90,
        "limitOrderExpiration": timedelta(minutes=5),
        # Coarse filter for the Universe selection. It selects nStrikes on both sides of the ATM
        # strike for each available expiration
        # Example: 200 SPX @ 3820 & 3910C w delta @ 1.95 => 90/5 = 18
        "nStrikesLeft": 18,
        "nStrikesRight": 18,
        # TODO fix this and set it based on buying power.
        "maxOrderQuantity": 1000,
        "targetPremiumPct": 0.015,
        # Minimum premium accepted for opening a new position. Setting this to None disables it.
        "minPremium": None,
        # Maximum premium accepted for opening a new position. Setting this to None disables it.
        "maxPremium": None,
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        "profitTarget": 1.0,
        "bidAskSpreadRatio": 0.4,
        "validateBidAskSpread": True,
        "marketCloseCutoffTime": None, #time(15, 45, 0),
        # Put/Call Wing size for Iron Condor, Iron Fly
        "putWingSize": 5,
        "callWingSize": 5,
        # "targetPremium": 500,
    }

    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
        # You can change the name here
        self.name = "SPXCondor"
        self.nameTag = "SPXCondor"
        self.ticker = "SPX"
        self.context.structure.AddUnderlying(self, self.ticker)

    def getOrder(self, chain, data):
        # Best time to open the trade: 9:45 + 10:15 + 12:30 + 13:00 + 13:30 + 13:45 + 14:00 + 15:00 + 15:15 + 15:45
        # https://tradeautomationtoolbox.com/byob-ticks/?save=admZ4dG
        if data.ContainsKey(self.underlyingSymbol):
            trade_times = [time(9, 45, 0), time(13, 10, 0), time(15, 15, 0)]
            current_time = self.context.Time.time()
            if current_time not in trade_times:
                return None
            strike = self.order.strategyBuilder.getATMStrike(chain)
            condor =  self.order.getIronCondorOrder(
                chain,
                callStrike=strike + 30,
                callWingSize=self.callWingSize,
                putStrike=strike - 30,
                putWingSize=self.putWingSize,
                sell=True
            )
            if condor is not None:
                return condor
            else:
                return None
        else:
            return None

        

#region imports
from AlgorithmImports import *
#endregion

from .Base import Base

class SPXic(Base):
    PARAMETERS = {
        # The start time at which the algorithm will start scheduling the strategy execution (to open new positions). No positions will be opened before this time
        "scheduleStartTime": time(9, 30, 0),
        # The stop time at which the algorithm will look to open a new position.
        "scheduleStopTime": time(16, 0, 0),
        # Periodic interval with which the algorithm will check to open new positions
        "scheduleFrequency": timedelta(minutes = 5),
        # Maximum number of open positions at any given time
        "maxActivePositions": 10,
        # Control whether to allow multiple positions to be opened for the same Expiration date
        "allowMultipleEntriesPerExpiry": True,
        # Minimum time distance between opening two consecutive trades
        "minimumTradeScheduleDistance": timedelta(minutes=10),
        # Days to Expiration
        "dte": 0,
        # The size of the window used to filter the option chain: options expiring in the range [dte-dteWindow, dte] will be selected
        "dteWindow": 0,
        "useLimitOrders": True,
        "limitOrderRelativePriceAdjustment": 0.2,
        # Alternative method to set the absolute price (per contract) of the Limit Order. This method is used if a number is specified
        "limitOrderAbsolutePrice": 1.0,
        "limitOrderExpiration": timedelta(minutes=5),
        # Coarse filter for the Universe selection. It selects nStrikes on both sides of the ATM
        # strike for each available expiration
        # Example: 200 SPX @ 3820 & 3910C w delta @ 1.95 => 90/5 = 18
        "nStrikesLeft": 18,
        "nStrikesRight": 18,
        # TODO fix this and set it based on buying power.
        # "maxOrderQuantity": 200,
        # COMMENT OUT this one below because it caused the orderQuantity to be 162 and maxOrderQuantity to be 10 so it would not place trades.
        "targetPremiumPct": 0.005,
        "validateQuantity": False,
        # Minimum premium accepted for opening a new position. Setting this to None disables it.
        "minPremium": 0.9,
        # Maximum premium accepted for opening a new position. Setting this to None disables it.
        "maxPremium": 1.2,
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        "profitTarget": 1.0,
        "bidAskSpreadRatio": 0.4,
        "validateBidAskSpread": True,
        "marketCloseCutoffTime": None, #time(15, 45, 0),
        # Put/Call Wing size for Iron Condor, Iron Fly
        "putWingSize": 10,
        "callWingSize": 10,
        # "targetPremium": 500,
    }

    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
        # You can change the name here
        self.name = "SPXic"
        self.nameTag = "SPXic"
        self.ticker = "SPX"
        self.context.structure.AddUnderlying(self, self.ticker)
        self.logger.debug(f"{self.__class__.__name__} -> __init__ -> AddUnderlying")


    def getOrder(self, chain, data):
        self.logger.debug(f"{self.__class__.__name__} -> getOrder -> start")
        self.logger.debug(f"SPXic -> getOrder -> data.ContainsKey(self.underlyingSymbol): {data.ContainsKey(self.underlyingSymbol)}")
        self.logger.debug(f"SPXic -> getOrder -> Underlying Symbol: {self.underlyingSymbol}")
        # Best time to open the trade: 9:45 + 10:15 + 12:30 + 13:00 + 13:30 + 13:45 + 14:00 + 15:00 + 15:15 + 15:45
        # https://tradeautomationtoolbox.com/byob-ticks/?save=admZ4dG
        if data.ContainsKey(self.underlyingSymbol):
            self.logger.debug(f"SPXic -> getOrder: Data contains key {self.underlyingSymbol}")
            # trade_times = [time(9, 45, 0), time(10, 15, 0), time(12, 30, 0), time(13, 0, 0), time(13, 30, 0), time(13, 45, 0), time(14, 0, 0), time(15, 0, 0), time(15, 15, 0), time(15, 45, 0)]
            trade_times = [time(9, 45, 0), time(10, 15, 0), time(12, 30, 0), time(13, 0, 0), time(13, 30, 0), time(13, 45, 0), time(14, 0, 0)]
            # trade_times = [time(hour, minute, 0) for hour in range(9, 15) for minute in range(0, 60, 30) if not (hour == 15 and minute > 0)]
            # Remove the microsecond from the current time
            current_time = self.context.Time.time().replace(microsecond=0)
            self.logger.debug(f"SPXic -> getOrder -> current_time: {current_time}")
            self.logger.debug(f"SPXic -> getOrder -> trade_times: {trade_times}")
            self.logger.debug(f"SPXic -> getOrder -> current_time in trade_times: {current_time in trade_times}")
            if current_time not in trade_times:
                return None
            call =  self.order.getSpreadOrder(
                chain,
                'call',
                fromPrice=self.minPremium,
                toPrice=self.maxPremium,
                wingSize=self.callWingSize,
                sell=True
            )
            put = self.order.getSpreadOrder(
                chain,
                'put',
                fromPrice=self.minPremium,
                toPrice=self.maxPremium,
                wingSize=self.putWingSize,
                sell=True
            )
            self.logger.debug(f"SPXic -> getOrder: Call: {call}")
            self.logger.debug(f"SPXic -> getOrder: Put: {put}")
            if call is not None and put is not None:
                return [call, put]
            else:
                return None
        else:
            return None

        

#region imports
from AlgorithmImports import *
#endregion

import numpy as np
from .OrderBuilder import OrderBuilder
from Tools import ContractUtils, BSM, Logger
from Strategy import Position


class Order:
    def __init__(self, context, base):
        self.context = context
        self.base = base
        # Set the logger
        self.logger = Logger(context, className=type(self).__name__, logLevel=context.logLevel)
        # Initialize the BSM pricing model
        self.bsm = BSM(context)
        # Initialize the contract utils
        self.contractUtils = ContractUtils(context)
        # Initialize the Strategy Builder
        self.strategyBuilder = OrderBuilder(context)

    # Function to evaluate the P&L of the position
    def fValue(self, spotPrice, contracts, sides=None, atTime=None, openPremium=None):
        # Compute the theoretical value at the given Spot price and point in time
        prices = np.array(
            [
                self.bsm.bsmPrice(
                    contract,
                    sigma=contract.BSMImpliedVolatility,
                    spotPrice=spotPrice,
                    atTime=atTime,
                )
                for contract in contracts
            ]
        )
        # Total value of the position
        value = openPremium + sum(prices * np.array(sides))
        return value

    def getPayoff(self, spotPrice, contracts, sides):
        # Exit if there are no contracts to process
        if len(contracts) == 0:
            return 0

        # Initialize the counter
        n = 0
        # initialize the payoff
        payoff = 0
        for contract in contracts:
            # direction: Call -> +1, Put -> -1
            direction = 2*int(contract.Right == OptionRight.Call)-1
            # Add the payoff of the current contract
            payoff += sides[n] * max(0, direction * (spotPrice - contract.Strike))
            # Increment the counter
            n += 1

        # Return the payoff
        return payoff

    def computeOrderMaxLoss(self, contracts, sides):
        # Exit if there are no contracts to process
        if len(contracts) == 0:
            return 0

        # Get the current price of the underlying
        UnderlyingLastPrice = self.contractUtils.getUnderlyingLastPrice(contracts[0])
        # Evaluate the payoff at the extreme (spotPrice = 0)
        maxLoss = self.getPayoff(0, contracts, sides)
        # Evaluate the payoff at each strike
        for contract in contracts:
            maxLoss = min(maxLoss, self.getPayoff(contract.Strike, contracts, sides))

        # Evaluate the payoff at the extreme (spotPrice = 10x higher)
        maxLoss = min(maxLoss, self.getPayoff(UnderlyingLastPrice*10, contracts, sides))
        # Cap the payoff at zero: we are only interested in losses
        maxLoss = min(0, maxLoss)
        # Return the max loss
        return maxLoss

    def getMaxOrderQuantity(self):
        # Get the context
        context = self.context

        # Get the maximum order quantity parameter
        maxOrderQuantity = self.base.maxOrderQuantity
        # Get the targetPremiumPct
        targetPremiumPct = self.base.targetPremiumPct
        # Check if we are using dynamic premium targeting
        if targetPremiumPct != None:
            # Scale the maxOrderQuantity consistently with the portfolio growth
            maxOrderQuantity = round(maxOrderQuantity * (1 + context.Portfolio.TotalProfit / context.initialAccountValue))
            # Make sure we don't go below the initial parameter value
            maxOrderQuantity = max(self.base.maxOrderQuantity, maxOrderQuantity)
        # Return the result
        return maxOrderQuantity

    def isDuplicateOrder(self, contracts, sides):
        # Loop through all working orders of this strategy
        for orderTag in list(self.context.workingOrders):
            # Get the current working order
            workingOrder = self.context.workingOrders.get(orderTag)
            # Check if the number of contracts of this working order is the same as the number of contracts in the input list
            if workingOrder and workingOrder.insights == len(contracts):
                # Initialize the isDuplicate flag. Assume it's duplicate unless we find a mismatch
                isDuplicate = True
                # Loop through each pair (contract, side)
                for contract, side in zip(contracts, sides):
                    # Get the details of the contract
                    contractInfo = workingOrder.get(contract.Symbol)
                # If we cannot find this contract then it's not a duplicate
                if contractInfo == None:
                    isDuplicate = False
                    break
                # Get the orderSide and expiryStr properties
                orderSide = contractInfo.get("orderSide")
                expiryStr = contractInfo.get("expiryStr")
                # Check for a mismatch
                if (orderSide != side # Found the contract but it's on a different side (Sell/Buy)
                    or expiryStr != contract.Expiry.strftime("%Y-%m-%d") # Found the contract but it's on a different Expiry
                    ):
                    # It's not a duplicate. Brake this innermost loop
                    isDuplicate = False
                    break
                # Exit if we found a duplicate
                if isDuplicate:
                    return isDuplicate

        # If we got this far, there are no duplicates
        return False

    def limitOrderPrice(self, sides, orderMidPrice):
        # Get the limitOrderAbsolutePrice
        limitOrderAbsolutePrice = self.base.limitOrderAbsolutePrice
        # Get the minPremium and maxPremium to determine the limit price based on that.
        minPremium = self.base.minPremium
        maxPremium = self.base.maxPremium
        # Get the limitOrderRelativePriceAdjustment
        limitOrderRelativePriceAdjustment = self.base.limitOrderRelativePriceAdjustment or 0.0

        # Compute Limit Order price
        if limitOrderAbsolutePrice is not None:
            if abs(orderMidPrice) < 1e-5:
                limitOrderRelativePriceAdjustment = 0
            else:
                # Compute the relative price adjustment (needed to adjust each leg with the same proportion)
                limitOrderRelativePriceAdjustment = limitOrderAbsolutePrice / orderMidPrice - 1
            # Use the specified absolute price
            limitOrderPrice = limitOrderAbsolutePrice
        else:
            # Set the Limit Order price (including slippage)
            limitOrderPrice = orderMidPrice * (1 + limitOrderRelativePriceAdjustment)

        # Compute the total slippage
        totalSlippage = sum(list(map(abs, sides))) * self.base.slippage
        # Add slippage to the limit order
        limitOrderPrice -= totalSlippage

        # Adjust the limit order price based on minPremium and maxPremium
        if minPremium is not None and limitOrderPrice < minPremium:
            limitOrderPrice = minPremium
        if maxPremium is not None and limitOrderPrice > maxPremium:
            limitOrderPrice = maxPremium

        return limitOrderPrice

    # Create dictionary with the details of the order to be submitted
    def getOrderDetails(self, contracts, sides, strategy, sell=True, strategyId=None, expiry=None, sidesDesc=None):
        # Exit if there are no contracts to process
        if not contracts:
            return

        # Exit if we already have a working order for the same set of contracts and sides
        if self.isDuplicateOrder(contracts, sides):
            return

        # Get the context
        context = self.context

        # Set the Strategy Id (if not specified)
        strategyId = strategyId or strategy.replace(" ", "")

        # Get the Expiration from the first contract (unless otherwise specified
        expiry = expiry or contracts[0].Expiry
        # Get the last trading day for the given expiration date (in case it falls on a holiday)
        expiryLastTradingDay = self.context.lastTradingDay(expiry)
        # Set the date/time threshold by which the position must be closed (on the last trading day before expiration)
        expiryMarketCloseCutoffDttm = None
        if self.base.marketCloseCutoffTime != None:
            expiryMarketCloseCutoffDttm = datetime.combine(expiryLastTradingDay, self.base.marketCloseCutoffTime)
        # Dictionary to map each contract symbol to the side (short/long)
        contractSide = {}
        # Dictionary to map each contract symbol to its description
        contractSideDesc = {}
        # Dictionary to map each contract symbol to the actual contract object
        contractDictionary = {}

        # Dictionaries to keep track of all the strikes, Delta and IV
        strikes = {}
        delta = {}
        gamma = {}
        vega = {}
        theta = {}
        rho = {}
        vomma = {}
        elasticity = {}
        IV = {}
        midPrices = {}
        contractExpiry = {}

        # Compute the Greeks for each contract (if not already available)
        if self.base.computeGreeks:
            self.bsm.setGreeks(contracts)

        # Compute the Mid-Price and Bid-Ask spread for the full order
        orderMidPrice = 0.0
        bidAskSpread = 0.0
        # Get the slippage parameter (if available)
        slippage = self.base.slippage or 0.0

        # Get the maximum order quantity
        maxOrderQuantity = self.getMaxOrderQuantity()
        # Get the targetPremiumPct
        targetPremiumPct = self.base.targetPremiumPct
        # Check if we are using dynamic premium targeting
        if targetPremiumPct != None:
            # Make sure targetPremiumPct is bounded to the range [0, 1])
            targetPremiumPct = max(0.0, min(1.0, targetPremiumPct))
            # Compute the target premium as a percentage of the total net portfolio value
            targetPremium = context.Portfolio.TotalPortfolioValue * targetPremiumPct
        else:
            targetPremium = self.base.targetPremium

        # Check if we have a description for the contracts
        if sidesDesc == None:
            # Temporary dictionaries to lookup a description
            optionTypeDesc = {OptionRight.Put: "Put", OptionRight.Call: "Call"}
            optionSideDesc = {-1: "short", 1: "long"}
            # create a description for each contract: <long|short><Call|Put>
            sidesDesc = list(map(lambda contract, side: f"{optionSideDesc[np.sign(side)]}{optionTypeDesc[contract.Right]}", contracts, sides))

        n = 0
        for contract in contracts:
            # Contract Side: +n -> Long, -n -> Short
            orderSide = sides[n]
            # Contract description (<long|short><Call|Put>)
            orderSideDesc = sidesDesc[n]

            # Store it in the dictionary
            contractSide[contract.Symbol] = orderSide
            contractSideDesc[contract.Symbol] = orderSideDesc
            contractDictionary[contract.Symbol] = contract

            # Set the strike in the dictionary -> "<short|long><Call|Put>": <strike>
            strikes[f"{orderSideDesc}"] = contract.Strike
            # Add the contract expiration time and add 16 hours to the market close
            contractExpiry[f"{orderSideDesc}"] = contract.Expiry + timedelta(hours = 16)
            if hasattr(contract, "BSMGreeks"):
                # Set the Greeks and IV in the dictionary -> "<short|long><Call|Put>": <greek|IV>
                delta[f"{orderSideDesc}"] = contract.BSMGreeks.Delta
                gamma[f"{orderSideDesc}"] = contract.BSMGreeks.Gamma
                vega[f"{orderSideDesc}"] = contract.BSMGreeks.Vega
                theta[f"{orderSideDesc}"] = contract.BSMGreeks.Theta
                rho[f"{orderSideDesc}"] = contract.BSMGreeks.Rho
                vomma[f"{orderSideDesc}"] = contract.BSMGreeks.Vomma
                elasticity[f"{orderSideDesc}"] = contract.BSMGreeks.Elasticity
                IV[f"{orderSideDesc}"] = contract.BSMImpliedVolatility

            # Get the latest mid-price
            midPrice = self.contractUtils.midPrice(contract)
            # Store the midPrice in the dictionary -> "<short|long><Call|Put>": midPrice
            midPrices[f"{orderSideDesc}"] = midPrice
            # Compute the bid-ask spread
            bidAskSpread += self.contractUtils.bidAskSpread(contract)
            # Adjusted mid-price (include slippage). Take the sign of orderSide to determine the direction of the adjustment
            # adjustedMidPrice = midPrice + np.sign(orderSide) * slippage
            # Keep track of the total credit/debit or the order
            orderMidPrice -= orderSide * midPrice

            # Increment counter
            n += 1

        limitOrderPrice = self.limitOrderPrice(sides=sides, orderMidPrice=orderMidPrice)
        # Round the prices to the nearest cent
        orderMidPrice = round(orderMidPrice, 2)
        limitOrderPrice = round(limitOrderPrice, 2)

        # Determine which price is used to compute the order quantity
        if self.base.useLimitOrders:
            # Use the Limit Order price
            qtyMidPrice = limitOrderPrice
        else:
            # Use the contract mid-price
            qtyMidPrice = orderMidPrice

        if targetPremium == None:
            # No target premium was provided. Use maxOrderQuantity
            orderQuantity = maxOrderQuantity
        else:
            # Make sure we are not exceeding the available portfolio margin
            targetPremium = min(context.Portfolio.MarginRemaining, targetPremium)

            # Determine the order quantity based on the target premium
            if abs(qtyMidPrice) <= 1e-5:
                orderQuantity = 1
            else:
                orderQuantity = abs(targetPremium / (qtyMidPrice * 100))

            # Different logic for Credit vs Debit strategies
            if sell:  # Credit order
                # Sell at least one contract
                orderQuantity = max(1, round(orderQuantity))
            else:  # Debit order
                # Make sure the total price does not exceed the target premium
                orderQuantity = math.floor(orderQuantity)

        # Get the current price of the underlying
        security = context.Securities[self.base.underlyingSymbol]
        underlyingPrice = context.GetLastKnownPrice(security).Price

        # Compute MaxLoss
        maxLoss = self.computeOrderMaxLoss(contracts, sides)
        # Get the Profit Target percentage is specified (default is 50%)
        profitTargetPct = self.base.parameter("profitTarget", 0.5)
        # Compute T-Reg margin based on the MaxLoss
        TReg = min(0, orderMidPrice + maxLoss) * orderQuantity

        portfolioMarginStress = self.context.portfolioMarginStress
        if self.base.computeGreeks:
            # Compute the projected P&L of the position following a % movement of the underlying up or down
            portfolioMargin = min(
                0,
                self.fValue(underlyingPrice * (1-portfolioMarginStress), contracts, sides=sides, atTime=context.Time, openPremium=midPrice),
                self.fValue(underlyingPrice * (1+portfolioMarginStress), contracts, sides=sides, atTime=context.Time, openPremium=midPrice)
            ) * orderQuantity

        order = {
            "strategyId": strategyId,
            "expiry": expiry,
            "orderMidPrice": orderMidPrice,
            "limitOrderPrice": limitOrderPrice,
            "bidAskSpread": bidAskSpread,
            "orderQuantity": orderQuantity,
            "maxOrderQuantity": maxOrderQuantity,
            "targetPremium": targetPremium,
            "strikes": strikes,
            "sides": sides,
            "sidesDesc": sidesDesc,
            "contractSide": contractSide,
            "contractSideDesc": contractSideDesc,
            "contracts": contracts,
            "contractExpiry": contractExpiry,
            "creditStrategy": sell,
            "maxLoss": maxLoss,
            "expiryLastTradingDay": expiryLastTradingDay,
            "expiryMarketCloseCutoffDttm": expiryMarketCloseCutoffDttm
        }
        # Create order details
        # order = {"expiry": expiry
        #         , "expiryStr": expiry.strftime("%Y-%m-%d")
        #         , "expiryLastTradingDay": expiryLastTradingDay
        #         , "expiryMarketCloseCutoffDttm": expiryMarketCloseCutoffDttm
        #         , "strategyId": strategyId
        #         , "strategy": strategy
        #         , "sides": sides
        #         , "sidesDesc": sidesDesc
        #         , "contractExpiry": contractExpiry
        #         , "contractSide": contractSide
        #         , "contractSideDesc": contractSideDesc
        #         , "contractDictionary": contractDictionary
        #         , "strikes": strikes
        #         , "midPrices": midPrices
        #         , "delta": delta
        #         , "gamma": gamma
        #         , "vega": vega
        #         , "theta": theta
        #         , "rho": rho
        #         , "vomma": vomma
        #         , "elasticity": elasticity
        #         , "IV": IV
        #         , "contracts": contracts
        #         , "targetPremium": targetPremium
        #         , "maxOrderQuantity": maxOrderQuantity
        #         , "orderQuantity": orderQuantity
        #         , "creditStrategy": sell
        #         , "maxLoss": maxLoss
        #         , "TReg": TReg
        #         , "portfolioMargin": portfolioMargin
        #         , "open": {"orders": []
        #                     , "fills": 0
        #                     , "filled": False
        #                     , "stalePrice": False
        #                     , "orderMidPrice": orderMidPrice
        #                     , "limitOrderPrice": limitOrderPrice
        #                     , "qtyMidPrice": qtyMidPrice
        #                     , "limitOrder": parameters["useLimitOrders"]
        #                     , "limitOrderExpiryDttm": context.Time + parameters["limitOrderExpiration"]
        #                     , "bidAskSpread": bidAskSpread
        #                     , "fillPrice": 0.0
        #                     }
        #         , "close": {"orders": []
        #                     , "fills": 0
        #                     , "filled": False
        #                     , "stalePrice": False
        #                     , "orderMidPrice": 0.0
        #                     , "fillPrice": 0.0
        #                     }
        #         }

        # Determine the method used to calculate the profit target
        profitTargetMethod = self.base.parameter("profitTargetMethod", "Premium").lower()
        thetaProfitDays = self.base.parameter("thetaProfitDays", 0)
        # Set a custom profit target unless we are using the default Premium based methodology
        if profitTargetMethod != "premium":
            if profitTargetMethod == "theta" and thetaProfitDays > 0:
                # Calculate the P&L of the position at T+[thetaProfitDays]
                thetaPnL = self.fValue(underlyingPrice, contracts, sides=sides, atTime=context.Time + timedelta(days=thetaProfitDays), openPremium=midPrice)
                # Profit target is a percentage of the P&L calculated at T+[thetaProfitDays]
                profitTargetAmt = profitTargetPct * abs(thetaPnL) * orderQuantity
            elif profitTargetMethod == "treg":
                # Profit target is a percentage of the TReg requirement
                profitTargetAmt = profitTargetPct * abs(TReg) * orderQuantity
            elif profitTargetMethod == "margin":
                # Profit target is a percentage of the margin requirement
                profitTargetAmt = profitTargetPct * abs(portfolioMargin) * orderQuantity
            else:
                pass
            # Set the target profit for the position
            order["targetProfit"] = profitTargetAmt

        return order

    def getNakedOrder(self, contracts, type, strike = None, delta = None, fromPrice = None, toPrice = None, sell = True):
        if sell:
            # Short option contract
            sides = [-1]
            strategy = f"Short {type.title()}"
        else:
            # Long option contract
            sides = [1]
            strategy = f"Long {type.title()}"

        type = type.lower()
        if type == "put":
            # Get all Puts with a strike lower than the given strike and delta lower than the given delta
            sorted_contracts = self.strategyBuilder.getPuts(contracts, toDelta = delta, toStrike = strike, fromPrice = fromPrice, toPrice = toPrice)
        elif type == "call":
            # Get all Calls with a strike higher than the given strike and delta lower than the given delta
            sorted_contracts = self.strategyBuilder.getCalls(contracts, toDelta = delta, fromStrike = strike, fromPrice = fromPrice, toPrice = toPrice)
        else:
            self.logger.error(f"Input parameter type = {type} is invalid. Valid values: Put|Call.")
            return

        # Check if we got any contracts
        if len(sorted_contracts):
            # Create order details
            order = self.getOrderDetails([sorted_contracts[0]], sides, strategy, sell)
            # Return the order
            return order


    # Create order details for a Straddle order
    def getStraddleOrder(self, contracts, strike = None, netDelta = None, sell = True):

        if sell:
            # Short Straddle
            sides = [-1, -1]
            strategy = "Short Straddle"
        else:
            # Long Straddle
            sides = [1, 1]
            strategy = "Long Straddle"

        # Delta strike selection (in case the Iron Fly is not centered on the ATM strike)
        delta = None
        # Make sure the netDelta is less than 50
        if netDelta != None and abs(netDelta) < 50:
            delta = 50 + netDelta

        if strike == None and delta == None:
            # Standard Straddle: get the ATM contracts
            legs = self.strategyBuilder.getATM(contracts)
        else:
            legs = []
            # This is a Straddle centered at the given strike or Net Delta.
            # Get the Put at the requested delta or strike
            puts = self.strategyBuilder.getPuts(contracts, toDelta = delta, toStrike = strike)
            if(len(puts) > 0):
                put = puts[0]

                # Get the Call at the same strike as the Put
                calls = self.strategyBuilder.getCalls(contracts, fromStrike = put.Strike)
                if(len(calls) > 0):
                    call = calls[0]
                # Collect both legs
                legs = [put, call]

        # Create order details
        order = self.getOrderDetails(legs, sides, strategy, sell)
        # Return the order
        return order


    # Create order details for a Strangle order
    def getStrangleOrder(self, contracts, callDelta = None, putDelta = None, callStrike = None, putStrike = None, sell = True):

        if sell:
            # Short Strangle
            sides = [-1, -1]
            strategy = "Short Strangle"
        else:
            # Long Strangle
            sides = [1, 1]
            strategy = "Long Strangle"

        # Get all Puts with a strike lower than the given putStrike and delta lower than the given putDelta
        puts = self.strategyBuilder.getPuts(contracts, toDelta = putDelta, toStrike = putStrike)
        # Get all Calls with a strike higher than the given callStrike and delta lower than the given callDelta
        calls = self.strategyBuilder.getCalls(contracts, toDelta = callDelta, fromStrike = callStrike)

        # Get the two contracts
        legs = []
        if len(puts) > 0 and len(calls) > 0:
            legs = [puts[0], calls[0]]

        # Create order details
        order = self.getOrderDetails(legs, sides, strategy, sell)
        # Return the order
        return order


    def getSpreadOrder(self, contracts, type, strike = None, delta = None, wingSize = None, sell = True, fromPrice = None, toPrice = None, premiumOrder = "max"):

        if sell:
            # Credit Spread
            sides = [-1, 1]
            strategy = f"{type.title()} Credit Spread"
        else:
            # Debit Spread
            sides = [1, -1]
            strategy = f"{type.title()} Debit Spread"

        # Get the legs of the spread
        legs = self.strategyBuilder.getSpread(contracts, type, strike = strike, delta = delta, wingSize = wingSize, fromPrice = fromPrice, toPrice = toPrice, premiumOrder = premiumOrder)
        self.logger.debug(f"getSpreadOrder -> legs: {legs}")
        self.logger.debug(f"getSpreadOrder -> sides: {sides}")
        self.logger.debug(f"getSpreadOrder -> strategy: {strategy}")
        self.logger.debug(f"getSpreadOrder -> sell: {sell}")
        # Exit if we couldn't get both legs of the spread
        if len(legs) != 2:
            return

        # Create order details
        order = self.getOrderDetails(legs, sides, strategy, sell)
        # Return the order
        return order


    def getIronCondorOrder(self, contracts, callDelta = None, putDelta = None, callStrike = None, putStrike = None, callWingSize = None, putWingSize = None, sell = True):

        if sell:
            # Sell Iron Condor: [longPut, shortPut, shortCall, longCall]
            sides = [1, -1, -1, 1]
            strategy = "Iron Condor"
        else:
            # Buy Iron Condor: [shortPut, longPut, longCall, shortCall]
            sides = [-1, 1, 1, -1]
            strategy = "Reverse Iron Condor"

        # Get the Put spread
        puts = self.strategyBuilder.getSpread(contracts, "Put", strike = putStrike, delta = putDelta, wingSize = putWingSize, sortByStrike = True)
        # Get the Call spread
        calls = self.strategyBuilder.getSpread(contracts, "Call", strike = callStrike, delta = callDelta, wingSize = callWingSize)

        # Collect all legs
        legs = puts + calls

        # Exit if we couldn't get all legs of the Iron Condor
        if len(legs) != 4:
            return

        # Create order details
        order = self.getOrderDetails(legs, sides, strategy, sell)
        # Return the order
        return order


    def getIronFlyOrder(self, contracts, netDelta = None, strike = None, callWingSize = None, putWingSize = None, sell = True):

        if sell:
            # Sell Iron Fly: [longPut, shortPut, shortCall, longCall]
            sides = [1, -1, -1, 1]
            strategy = "Iron Fly"
        else:
            # Buy Iron Fly: [shortPut, longPut, longCall, shortCall]
            sides = [-1, 1, 1, -1]
            strategy = "Reverse Iron Fly"

        # Delta strike selection (in case the Iron Fly is not centered on the ATM strike)
        delta = None
        # Make sure the netDelta is less than 50
        if netDelta != None and abs(netDelta) < 50:
            delta = 50 + netDelta

        if strike == None and delta == None:
            # Standard ATM Iron Fly
            strike = self.strategyBuilder.getATMStrike(contracts)

        # Get the Put spread
        puts = self.strategyBuilder.getSpread(contracts, "Put", strike = strike, delta = delta, wingSize = putWingSize, sortByStrike = True)
        # Get the Call spread with the same strike as the first leg of the Put spread
        calls = self.strategyBuilder.getSpread(contracts, "Call", strike = puts[-1].Strike, wingSize = callWingSize)

        # Collect all legs
        legs = puts + calls

        # Exit if we couldn't get all legs of the Iron Fly
        if len(legs) != 4:
            return

        # Create order details
        order = self.getOrderDetails(legs, sides, strategy, sell)
        # Return the order
        return order


    def getButterflyOrder(self, contracts, type, netDelta = None, strike = None, leftWingSize = None, rightWingSize = None, sell = False):

        # Make sure the wing sizes are set
        leftWingSize = leftWingSize or rightWingSize or 1
        rightWingSize = rightWingSize or leftWingSize or 1

        if sell:
            # Sell Butterfly: [short<Put|Call>, 2 long<Put|Call>, short<Put|Call>]
            sides = [-1, 2, -1]
            strategy = "Credit Butterfly"
        else:
            # Buy Butterfly: [long<Put|Call>, 2 short<Put|Call>, long<Put|Call>]
            sides = [1, -2, 1]
            strategy = "Debit Butterfly"

        # Create a custom description for each side to uniquely identify the wings:
        # Sell Butterfly: [leftShort<Put|Call>, 2 Long<Put|Call>, rightShort<Put|Call>]
        # Buy Butterfly: [leftLong<Put|Call>, 2 Short<Put|Call>, rightLong<Put|Call>]
        optionSides = {-1: "Short", 1: "Long"}
        sidesDesc = list(map(lambda side, prefix: f"{prefix}{optionSides[np.sign(side)]}{type.title()}", sides, ["left", "", "right"]))


        # Delta strike selection (in case the Butterfly is not centered on the ATM strike)
        delta = None
        # Make sure the netDelta is less than 50
        if netDelta != None and abs(netDelta) < 50:
            if type.lower() == "put":
                # Use Put delta
                delta = 50 + netDelta
            else:
                # Use Call delta
                delta = 50 - netDelta

        if strike == None and delta == None:
            # Standard ATM Butterfly
            strike = self.strategyBuilder.getATMStrike(contracts)

        type = type.lower()
        if type == "put":
            # Get the Put spread (sorted by strike in ascending order)
            putSpread = self.strategyBuilder.getSpread(contracts, "Put", strike = strike, delta = delta, wingSize = leftWingSize, sortByStrike = True)
            # Exit if we couldn't get all legs of the Iron Fly
            if len(putSpread) != 2:
                return
            # Get the middle strike (second entry in the list)
            middleStrike = putSpread[1].Strike
            # Find the right wing of the Butterfly (add a small offset to the fromStrike in order to avoid selecting the middle strike as a wing)
            wings = self.strategyBuilder.getPuts(contracts, fromStrike = middleStrike + 0.1, toStrike = middleStrike + rightWingSize)
            # Exit if we could not find the wing
            if len(wings) == 0:
                return
            # Combine all the legs
            legs = putSpread + wings[0]
        elif type == "call":
            # Get the Call spread (sorted by strike in ascending order)
            callSpread = self.strategyBuilder.getSpread(contracts, "Call", strike = strike, delta = delta, wingSize = rightWingSize)
            # Exit if we couldn't get all legs of the Iron Fly
            if len(callSpread) != 2:
                return
            # Get the middle strike (first entry in the list)
            middleStrike = callSpread[0].Strike
            # Find the left wing of the Butterfly (add a small offset to the toStrike in order to avoid selecting the middle strike as a wing)
            wings = self.strategyBuilder.getCalls(contracts, fromStrike = middleStrike - leftWingSize, toStrike = middleStrike - 0.1)
            # Exit if we could not find the wing
            if len(wings) == 0:
                return
            # Combine all the legs
            legs = wings[0] + callSpread
        else:
            self.logger.error(f"Input parameter type = {type} is invalid. Valid values: Put|Call.")
            return

        # Exit if we couldn't get both legs of the spread
        if len(legs) != 3:
            return

        # Create order details
        order = self.getOrderDetails(legs, sides, strategy, sell = sell, sidesDesc = sidesDesc)
        # Return the order
        return order


    def getCustomOrder(self, contracts, types, deltas = None, sides = None, sidesDesc = None, strategy = "Custom", sell = None):

        # Make sure the Sides parameter has been specified
        if not sides:
            self.logger.error("Input parameter sides cannot be null. No order will be returned.")
            return

        # Make sure the Sides and Deltas parameters are of the same length
        if not deltas or len(deltas) != len(sides):
            self.logger.error(f"Input parameters deltas = {deltas} and sides = {sides} must have the same length. No order will be returned.")
            return

        # Convert types into a list if it is a string
        if isinstance(types, str):
            types = [types] * len(sides)

        # Make sure the Sides and Types parameters are of the same length
        if not types or len(types) != len(sides):
            self.logger.error(f"Input parameters types = {types} and sides = {sides} must have the same length. No order will be returned.")
            return

        legs = []
        midPrice = 0
        for side, type, delta in zip(sides, types, deltas):
            # Get all Puts with a strike lower than the given putStrike and delta lower than the given putDelta
            deltaContracts = self.strategyBuilder.getContracts(contracts, type = type, toDelta = delta, reverse = type.lower() == "put")
            # Exit if we could not find the contract
            if not deltaContracts:
                return
            # Append the contract to the list of legs
            legs = legs + [deltaContracts[0]]
            # Update the mid-price
            midPrice -= self.contractUtils.midPrice(deltaContracts[0]) * side

        # Automatically determine if this is a credit or debit strategy (unless specified)
        if sell is None:
            sell = midPrice > 0

        # Create order details
        order = self.getOrderDetails(legs, sides, strategy, sell = sell, sidesDesc = sidesDesc)
        # Return the order
        return order
# region imports
from AlgorithmImports import *
# endregion

from Tools import Logger, ContractUtils, BSM


"""
This is an Order builder class. It will get the proper contracts i need to create the order per parameters.
"""


class OrderBuilder:
    # \param[in] context is a reference to the QCAlgorithm instance. The following attributes are used from the context:
    #    - slippage: (Optional) controls how the mid-price of an order is adjusted to include slippage.
    #    - targetPremium: (Optional) used to determine how many contracts to buy/sell.
    #    - maxOrderQuantity: (Optional) Caps the number of contracts that are bought/sold (Default: 1).
    #         If targetPremium == None  -> This is the number of contracts bought/sold.
    #         If targetPremium != None  -> The order is executed only if the number of contracts required
    #           to reach the target credit/debit does not exceed the maxOrderQuantity
    def __init__(self, context):
        # Set the context (QCAlgorithm object)
        self.context = context
        # Initialize the BSM pricing model
        self.bsm = BSM(context)
        # Set the logger
        self.logger = Logger(context, className=type(self).__name__, logLevel=context.logLevel)
        # Initialize the contract utils
        self.contractUtils = ContractUtils(context)

    # Returns True/False based on whether the option contract is of the specified type (Call/Put)
    def optionTypeFilter(self, contract, type = None):
        if type is None:
            return True

        type = type.lower()
        if type == "put":
            return contract.Right == OptionRight.Put
        elif type == "call":
            return contract.Right == OptionRight.Call
        else:
            return True

    # Return the ATM contracts (Put/Call or both)
    def getATM(self, contracts, type = None):

        # Initialize result
        atm_contracts = []

        # Sort the contracts based on how close they are to the current price of the underlying.
        # Filter them by the selected contract type (Put/Call or both)
        sorted_contracts = sorted([contract
                                        for contract in contracts
                                        if self.optionTypeFilter(contract, type)
                                    ]
                                    , key = lambda x: abs(x.Strike - self.contractUtils.getUnderlyingLastPrice(x))
                                    , reverse = False
                                    )

        # Check if any contracts were returned after the filtering
        if len(sorted_contracts) > 0:
            if type == None or type.lower() == "both":
                # Select the first two contracts (one Put and one Call)
                Ncontracts = min(len(sorted_contracts), 2)
            else:
                # Select the first contract (either Put or Call, based on the type specified)
                Ncontracts = 1
            # Extract the selected contracts
            atm_contracts = sorted_contracts[0:Ncontracts]
        # Return result
        return atm_contracts

    def getATMStrike(self, contracts):
        ATMStrike = None
        # Get the ATM contracts
        atm_contracts = self.getATM(contracts)
        # Check if any contracts were found
        if len(atm_contracts) > 0:
            # Get the Strike of the first contract
            ATMStrike = atm_contracts[0].Strike
        # Return result
        return ATMStrike

    # Returns the Strike of the contract with the closest Delta
    # Assumptions:
    #  - Input list contracts must be sorted by ascending strike
    #  - All contracts in the list must be of the same type (Call|Put)
    def getDeltaContract(self, contracts, delta = None):
        # Skip processing if the option type or Delta has not been specified
        if delta == None or not contracts:
            return

        leftIdx = 0
        rightIdx = len(contracts)-1

        # Compute the Greeks for the contracts at the extremes
        self.bsm.setGreeks([contracts[leftIdx], contracts[rightIdx]])

        # #######################################################
        # Check if the requested Delta is outside of the range
        # #######################################################
        if contracts[rightIdx].Right == OptionRight.Call:
            # Check if the furthest OTM Call has a Delta higher than the requested Delta
            if abs(contracts[rightIdx].BSMGreeks.Delta) > delta/100.0:
                # The requested delta is outside the boundary, return the strike of the furthest OTM Call
                return contracts[rightIdx]
            # Check if the furthest ITM Call has a Delta lower than the requested Delta
            elif abs(contracts[leftIdx].BSMGreeks.Delta) < delta/100.0:
                # The requested delta is outside the boundary, return the strike of the furthest ITM Call
                return contracts[leftIdx]
        else:
            # Check if the furthest OTM Put has a Delta higher than the requested Delta
            if abs(contracts[leftIdx].BSMGreeks.Delta) > delta/100.0:
                # The requested delta is outside the boundary, return the strike of the furthest OTM Put
                return contracts[leftIdx]
            # Check if the furthest ITM Put has a Delta lower than the requested Delta
            elif abs(contracts[rightIdx].BSMGreeks.Delta) < delta/100.0:
                # The requested delta is outside the boundary, return the strike of the furthest ITM Put
                return contracts[rightIdx]

        # The requested Delta is inside the range, use the Bisection method to find the contract with the closest Delta
        while (rightIdx-leftIdx) > 1:
            # Get the middle point
            middleIdx = round((leftIdx + rightIdx)/2.0)
            middleContract = contracts[middleIdx]
            # Compute the greeks for the contract in the middle
            self.bsm.setGreeks(middleContract)
            contractDelta = contracts[middleIdx].BSMGreeks.Delta
            # Determine which side we need to continue the search
            if(abs(contractDelta) > delta/100.0):
                if middleContract.Right == OptionRight.Call:
                    # The requested Call Delta is on the right side
                    leftIdx = middleIdx
                else:
                    # The requested Put Delta is on the left side
                    rightIdx = middleIdx
            else:
                if middleContract.Right == OptionRight.Call:
                    # The requested Call Delta is on the left side
                    rightIdx = middleIdx
                else:
                    # The requested Put Delta is on the right side
                    leftIdx = middleIdx

        # At this point where should only be two contracts remaining: choose the contract with the closest Delta
        deltaContract = sorted([contracts[leftIdx], contracts[rightIdx]]
                                , key = lambda x: abs(abs(x.BSMGreeks.Delta) - delta/100.0)
                                , reverse = False
                                )[0]

        return deltaContract

    def getDeltaStrike(self, contracts, delta = None):
        deltaStrike = None
        # Get the contract with the closest Delta
        deltaContract = self.getDeltaContract(contracts, delta = delta)
        # Check if we got any contract
        if deltaContract != None:
            # Get the strike
            deltaStrike = deltaContract.Strike
        # Return the strike
        return deltaStrike

    def getFromDeltaStrike(self, contracts, delta = None, default = None):
        fromDeltaStrike = default
        # Get the call with the closest Delta
        deltaContract = self.getDeltaContract(contracts, delta = delta)
        # Check if we found the contract
        if deltaContract:
            if abs(deltaContract.BSMGreeks.Delta) >= delta/100.0:
                # The contract is in the required range. Get the Strike
                fromDeltaStrike = deltaContract.Strike
            else:
                # Calculate the offset: +0.01 in case of Puts, -0.01 in case of Calls
                offset = 0.01 * (2*int(deltaContract.Right == OptionRight.Put)-1)
                # The contract is outside of the required range. Get the Strike and add (Put) or subtract (Call) a small offset so we can filter for contracts above/below this strike
                fromDeltaStrike = deltaContract.Strike + offset
        return fromDeltaStrike

    def getToDeltaStrike(self, contracts, delta = None, default = None):
        toDeltaStrike = default
        # Get the put with the closest Delta
        deltaContract = self.getDeltaContract(contracts, delta = delta)
        # Check if we found the contract
        if deltaContract:
            if abs(deltaContract.BSMGreeks.Delta) <= delta/100.0:
                # The contract is in the required range. Get the Strike
                toDeltaStrike = deltaContract.Strike
            else:
                # Calculate the offset: +0.01 in case of Calls, -0.01 in case of Puts
                offset = 0.01 * (2*int(deltaContract.Right == OptionRight.Call)-1)
                # The contract is outside of the required range. Get the Strike and add (Call) or subtract (Put) a small offset so we can filter for contracts above/below this strike
                toDeltaStrike = deltaContract.Strike + offset
        return toDeltaStrike

    def getPutFromDeltaStrike(self, contracts, delta = None):
        return self.getFromDeltaStrike(contracts, delta = delta, default = 0.0)

    def getCallFromDeltaStrike(self, contracts, delta = None):
        return self.getFromDeltaStrike(contracts, delta = delta, default = float('Inf'))

    def getPutToDeltaStrike(self, contracts, delta = None):
        return self.getToDeltaStrike(contracts, delta = delta, default = float('Inf'))

    def getCallToDeltaStrike(self, contracts, delta = None):
        return self.getToDeltaStrike(contracts, delta = delta, default = 0)

    def getContracts(self, contracts, type = None, fromDelta = None, toDelta = None, fromStrike = None, toStrike = None, fromPrice = None, toPrice = None, reverse = False):
        # Make sure all constraints are set
        fromStrike = fromStrike or 0
        fromPrice = fromPrice or 0
        toStrike = toStrike or float('inf')
        toPrice = toPrice or float('inf')

        # Get the Put contracts, sorted by ascending strike. Apply the Strike/Price constraints
        puts = []
        if type == None or type.lower() == "put":
            puts = sorted([contract
                            for contract in contracts
                                if self.optionTypeFilter(contract, "Put")
                                # Strike constraint
                                and (fromStrike <= contract.Strike <= toStrike)
                                # The option contract is tradable
                                and self.contractUtils.getSecurity(contract).IsTradable
                                # Option price constraint (based on the mid-price)
                                and (fromPrice <= self.contractUtils.midPrice(contract) <= toPrice)
                        ]
                        , key = lambda x: x.Strike
                        , reverse = False
                        )

        # Get the Call contracts, sorted by ascending strike. Apply the Strike/Price constraints
        calls = []
        if type == None or type.lower() == "call":
            calls = sorted([contract
                            for contract in contracts
                                if self.optionTypeFilter(contract, "Call")
                                # Strike constraint
                                and (fromStrike <= contract.Strike <= toStrike)
                                # The option contract is tradable
                                and self.contractUtils.getSecurity(contract).IsTradable
                                # Option price constraint (based on the mid-price)
                                and (fromPrice <= self.contractUtils.midPrice(contract) <= toPrice)
                            ]
                            , key = lambda x: x.Strike
                            , reverse = False
                            )


        deltaFilteredPuts = puts
        deltaFilteredCalls = calls
        # Check if we need to filter by Delta
        if (fromDelta or toDelta):
            # Find the strike range for the Puts based on the From/To Delta
            putFromDeltaStrike = self.getPutFromDeltaStrike(puts, delta = fromDelta)
            putToDeltaStrike = self.getPutToDeltaStrike(puts, delta = toDelta)
            # Filter the Puts based on the delta-strike range
            deltaFilteredPuts = [contract for contract in puts
                                    if putFromDeltaStrike <= contract.Strike <= putToDeltaStrike
                                ]

            # Find the strike range for the Calls based on the From/To Delta
            callFromDeltaStrike = self.getCallFromDeltaStrike(calls, delta = fromDelta)
            callToDeltaStrike = self.getCallToDeltaStrike(calls, delta = toDelta)
            # Filter the Puts based on the delta-strike range. For the calls, the Delta decreases with increasing strike, so the order of the filter is inverted
            deltaFilteredCalls = [contract for contract in calls
                                    if callToDeltaStrike <= contract.Strike <= callFromDeltaStrike
                                ]


        # Combine the lists and Sort the contracts by their strike in the specified order.
        result = sorted(deltaFilteredPuts + deltaFilteredCalls
                        , key = lambda x: x.Strike
                        , reverse = reverse
                        )
        # Return result
        return result

    def getPuts(self, contracts, fromDelta = None, toDelta = None, fromStrike = None, toStrike = None, fromPrice = None, toPrice = None):

        # Sort the Put contracts by their strike in reverse order. Filter them by the specified criteria (Delta/Strike/Price constrains)
        return self.getContracts(contracts
                                , type = "Put"
                                , fromDelta = fromDelta
                                , toDelta = toDelta
                                , fromStrike = fromStrike
                                , toStrike = toStrike
                                , fromPrice = fromPrice
                                , toPrice = toPrice
                                , reverse = True
                                )

    def getCalls(self, contracts, fromDelta = None, toDelta = None, fromStrike = None, toStrike = None, fromPrice = None, toPrice = None):

        # Sort the Call contracts by their strike in ascending order. Filter them by the specified criteria (Delta/Strike/Price constrains)
        return self.getContracts(contracts
                                , type = "Call"
                                , fromDelta = fromDelta
                                , toDelta = toDelta
                                , fromStrike = fromStrike
                                , toStrike = toStrike
                                , fromPrice = fromPrice
                                , toPrice = toPrice
                                , reverse = False
                                )

    # Get the wing contract at the requested distance
    # Assumptions:
    #  - The input contracts are sorted by increasing distance from the ATM (ascending order for Calls, descending order for Puts)
    #  - The first contract in the list is assumed to be one of the legs of the spread, and it is used to determine the distance for the wing
    def getWing(self, contracts, wingSize = None):
        # Make sure the wingSize is specified
        wingSize = wingSize or 0

        # Initialize output
        wingContract = None

        if len(contracts) > 1 and wingSize > 0:
            # Get the short strike
            firstLegStrike = contracts[0].Strike
            # keep track of the wing size based on the long contract being selected
            currentWings = 0
            # Loop through all contracts
            for contract in contracts[1:]:
                # Select the long contract as long as it is within the specified wing size
                if abs(contract.Strike - firstLegStrike) <= wingSize:
                    currentWings = abs(contract.Strike - firstLegStrike)
                    wingContract = contract
                else:
                    # We have exceeded the wing size, check if the distance to the requested wing size is closer than the contract previously selected
                    if (abs(contract.Strike - firstLegStrike) - wingSize < wingSize - currentWings):
                        wingContract = contract
                    break
            ### Loop through all contracts
        ### if wingSize > 0

        return wingContract

    # Get Spread contracts (Put or Call)
    def getSpread(self, contracts, type, strike = None, delta = None, wingSize = None, sortByStrike = False, fromPrice = None, toPrice = None, premiumOrder = 'max'):
        # Type is a required parameter
        if type == None:
            self.logger.error(f"Input parameter type = {type} is invalid. Valid values: 'Put'|'Call'")
            return

        type = type.lower()
        if type == "put":
            # Get all Puts with a strike lower than the given strike and delta lower than the given delta
            sorted_contracts = self.getPuts(contracts, toDelta = delta, toStrike = strike)
        elif type == "call":
            # Get all Calls with a strike higher than the given strike and delta lower than the given delta
            sorted_contracts = self.getCalls(contracts, toDelta = delta, fromStrike = strike)
        else:
            self.logger.error(f"Input parameter type = {type} is invalid. Valid values: 'Put'|'Call'")
            return

        # Initialize the result and the best premium
        best_spread = []
        best_premium = -float('inf') if premiumOrder == 'max' else float('inf')
        self.logger.debug(f"wingSize: {wingSize}, premiumOrder: {premiumOrder}, fromPrice: {fromPrice}, toPrice: {toPrice}, sortByStrike: {sortByStrike}, strike: {strike}")
        if strike is not None:
            wing = self.getWing(sorted_contracts, wingSize = wingSize)
            self.logger.debug(f"STRIKE: wing: {wing}")
            # Check if we have any contracts
            if(len(sorted_contracts) > 0):
                # Add the first leg
                best_spread.append(sorted_contracts[0])
                if wing != None:
                    # Add the wing
                    best_spread.append(wing)
        else:
            # Iterate over sorted contracts
            for i in range(len(sorted_contracts) - 1):
                # Get the wing
                wing = self.getWing(sorted_contracts[i:], wingSize = wingSize)
                self.logger.debug(f"NO STRIKE: wing: {wing}")
                if wing is not None:
                    # Calculate the net premium
                    net_premium = abs(self.contractUtils.midPrice(sorted_contracts[i]) - self.contractUtils.midPrice(wing))
                    self.logger.debug(f"fromPrice: {fromPrice} <= net_premium: {net_premium} <= toPrice: {toPrice}")
                    # Check if the net premium is within the specified price range
                    if fromPrice <= net_premium <= toPrice:
                        # Check if this spread has a better premium
                        if (premiumOrder == 'max' and net_premium > best_premium) or (premiumOrder == 'min' and net_premium < best_premium):
                            best_spread = [sorted_contracts[i], wing]
                            best_premium = net_premium

        # By default, the legs of a spread are sorted based on their distance from the ATM strike.
        # - For Call spreads, they are already sorted by increasing strike
        # - For Put spreads, they are sorted by decreasing strike
        # In some cases it might be more convenient to return the legs ordered by their strike (i.e. in case of Iron Condors/Flys)
        if sortByStrike:
            best_spread = sorted(best_spread, key = lambda x: x.Strike, reverse = False)

        return best_spread

    # Get Put Spread contracts
    def getPutSpread(self, contracts, strike = None, delta = None, wingSize = None, sortByStrike = False):
        return self.getSpread(contracts, "Put", strike = strike, delta = delta, wingSize = wingSize, sortByStrike = sortByStrike)

    # Get Put Spread contracts
    def getCallSpread(self, contracts, strike = None, delta = None, wingSize = None, sortByStrike = True):
        return self.getSpread(contracts, "Call", strike = strike, delta = delta, wingSize = wingSize, sortByStrike = sortByStrike)
#region imports
from AlgorithmImports import *
#endregion

from Tools import BSM, Logger

class Scanner:
    def __init__(self, context, base):
        self.context = context
        self.base = base
        # Initialize the BSM pricing model
        self.bsm = BSM(context)
        # Dictionary to keep track of all the available expiration dates at any given date
        self.expiryList = {}
        # Set the logger
        self.logger = Logger(context, className = type(self).__name__, logLevel = context.logLevel)

    def Call(self, data) -> [Dict, str]:
        # Start the timer
        self.context.executionTimer.start('Alpha.Utils.Scanner -> Call')
        self.logger.trace(f'{self.base.name} -> Call -> start')
        if self.isMarketClosed():
            self.logger.trace(" -> Market is closed.")
            return None, None
        self.logger.debug(f'Market not closed')
        if not self.isWithinScheduledTimeWindow():
            self.logger.trace(" -> Not within scheduled time window.")
            return None, None
        self.logger.debug(f'Within scheduled time window')
        if self.hasReachedMaxActivePositions():
            self.logger.trace(" -> Already reached max active positions.")
            return None, None
        self.logger.debug(f'Not max active positions')
        # Get the option chain 
        chain = self.base.dataHandler.getOptionContracts(data)
        print(f'Number of contracts in chain: {len(chain) if chain else 0}')
        # Exit if we got no chains
        if chain is None:
            self.logger.debug(" -> No chains inside currentSlice!")
            return None, None
        self.logger.debug('We have chains inside currentSlice')
        self.syncExpiryList(chain)
        self.logger.debug(f'Expiry List: {self.expiryList}')
        # Exit if we haven't found any Expiration cycles to process
        if not self.expiryList:
            self.logger.trace(" -> No expirylist.")
            return None, None
        self.logger.debug(f'We have expirylist {self.expiryList}')
        # Run the strategy
        filteredChain, lastClosedOrderTag = self.Filter(chain)
        self.logger.debug(f'Filtered Chain Count: {len(filteredChain) if filteredChain else 0}')
        self.logger.debug(f'Last Closed Order Tag: {lastClosedOrderTag}')
        # Stop the timer
        self.context.executionTimer.stop('Alpha.Utils.Scanner -> Call')
        return filteredChain, lastClosedOrderTag

    # Filter the contracts to buy and sell based on the defined AlphaModel/Strategy
    def Filter(self, chain):
        # Start the timer
        self.context.executionTimer.start("Alpha.Utils.Scanner -> Filter")

        # Get the context
        context = self.context
        self.logger.debug(f'Context: {context}')
        # DTE range
        dte = self.base.dte
        dteWindow = self.base.dteWindow

        # Controls whether to select the furthest or the earliest expiry date
        useFurthestExpiry = self.base.useFurthestExpiry
        # Controls whether to enable dynamic selection of the expiry date
        dynamicDTESelection = self.base.dynamicDTESelection
        # Controls whether to allow multiple entries for the same expiry date
        allowMultipleEntriesPerExpiry = self.base.allowMultipleEntriesPerExpiry
        self.logger.debug(f'Allow Multiple Entries Per Expiry: {allowMultipleEntriesPerExpiry}')
        # Set the DTE range (make sure values are not negative)
        minDte = max(0, dte - dteWindow)
        maxDte = max(0, dte)
        self.logger.debug(f'Min DTE: {minDte}')
        self.logger.debug(f'Max DTE: {maxDte}')
        # Get the minimum time distance between consecutive trades
        minimumTradeScheduleDistance = self.base.parameter("minimumTradeScheduleDistance", timedelta(hours=0))
        # Make sure the minimum required amount of time has passed since the last trade was opened
        if (self.context.lastOpenedDttm is not None and context.Time < (self.context.lastOpenedDttm + minimumTradeScheduleDistance)):
            return None, None
        self.logger.debug(f'Min Trade Schedule Distance: {minimumTradeScheduleDistance}')
        # Check if the expiryList was specified as an input
        if self.expiryList is None:
            # List of expiry dates, sorted in reverse order
            self.expiryList = sorted(set([
                contract.Expiry for contract in chain
                if minDte <= (contract.Expiry.date() - context.Time.date()).days <= maxDte
            ]), reverse=True)
            self.logger.debug(f'Expiry List: {self.expiryList}')
            # Log the list of expiration dates found in the chain
            self.logger.debug(f"Expiration dates in the chain: {len(self.expiryList)}")
            for expiry in self.expiryList:
                self.logger.debug(f" -> {expiry}")
        self.logger.debug(f'Expiry List: {self.expiryList}')
        # Exit if we haven't found any Expiration cycles to process
        if not self.expiryList:
            # Stop the timer
            self.context.executionTimer.stop()
            return None, None
        self.logger.debug('No expirylist')
        # Get the DTE of the last closed position
        lastClosedDte = None
        lastClosedOrderTag = None
        if self.context.recentlyClosedDTE:
            while (self.context.recentlyClosedDTE):
                # Pop the oldest entry in the list (FIFO)
                lastClosedTradeInfo = self.context.recentlyClosedDTE.pop(0)
                if lastClosedTradeInfo["closeDte"] >= minDte:
                    lastClosedDte = lastClosedTradeInfo["closeDte"]
                    lastClosedOrderTag = lastClosedTradeInfo["orderTag"]
                    # We got a good entry, get out of the loop
                    break
        self.logger.debug(f'Last Closed DTE: {lastClosedDte}')
        self.logger.debug(f'Last Closed Order Tag: {lastClosedOrderTag}')
        # Check if we need to do dynamic DTE selection
        if dynamicDTESelection and lastClosedDte is not None:
            # Get the expiration with the nearest DTE as that of the last closed position
            expiry = sorted(self.expiryList,
                            key=lambda expiry: abs((expiry.date(
                            ) - context.Time.date()).days - lastClosedDte),
                            reverse=False)[0]
        else:
            # Determine the index used to select the expiry date:
            # useFurthestExpiry = True -> expiryListIndex = 0 (takes the first entry -> furthest expiry date since the expiry list is sorted in reverse order)
            # useFurthestExpiry = False -> expiryListIndex = -1 (takes the last entry -> earliest expiry date since the expiry list is sorted in reverse order)
            expiryListIndex = int(useFurthestExpiry) - 1
            # Get the expiry date
            expiry = list(self.expiryList.get(self.context.Time.date()))[expiryListIndex]
            # expiry = list(self.expiryList.keys())[expiryListIndex]
        self.logger.debug(f'Expiry: {expiry}')
        # Convert the date to a string
        expiryStr = expiry.strftime("%Y-%m-%d")

        filteredChain = None
        openPositionsExpiries = [self.context.allPositions[orderId].expiryStr for orderId in self.context.openPositions.values()]
        # Proceed if we have not already opened a position on the given expiration (unless we are allowed to open multiple positions on the same expiry date)
        if (allowMultipleEntriesPerExpiry or expiryStr not in openPositionsExpiries):
            # Filter the contracts in the chain, keep only the ones expiring on the given date
            filteredChain = self.filterByExpiry(chain, expiry=expiry)
        self.logger.debug(f'Number of items in Filtered Chain: {len(filteredChain) if filteredChain else 0}')
        # Stop the timer
        self.context.executionTimer.stop("Alpha.Utils.Scanner -> Filter")

        return filteredChain, lastClosedOrderTag

    def isMarketClosed(self) -> bool:
        # Exit if the algorithm is warming up or the market is closed
        return self.context.IsWarmingUp or not self.context.IsMarketOpen(self.base.underlyingSymbol)

    def isWithinScheduledTimeWindow(self) -> bool:
        # Compute the schedule start datetime
        scheduleStartDttm = datetime.combine(self.context.Time.date(), self.base.scheduleStartTime)
        self.logger.debug(f'Schedule Start Datetime: {scheduleStartDttm}')

        # Exit if we have not reached the schedule start datetime
        if self.context.Time < scheduleStartDttm:
            self.logger.debug('Current time is before the schedule start datetime')
            return False

        # Check if we have a schedule stop datetime
        if self.base.scheduleStopTime is not None:
            # Compute the schedule stop datetime
            scheduleStopDttm = datetime.combine(self.context.Time.date(), self.base.scheduleStopTime)
            self.logger.debug(f'Schedule Stop Datetime: {scheduleStopDttm}')
            # Exit if we have exceeded the stop datetime
            if self.context.Time > scheduleStopDttm:
                self.logger.debug('Current time is after the schedule stop datetime')
                return False

        minutesSinceScheduleStart = round((self.context.Time - scheduleStartDttm).seconds / 60)
        self.logger.debug(f'Minutes Since Schedule Start: {minutesSinceScheduleStart}')
        scheduleFrequencyMinutes = round(self.base.scheduleFrequency.seconds / 60)
        self.logger.debug(f'Schedule Frequency Minutes: {scheduleFrequencyMinutes}')

        # Exit if we are not at the right scheduled interval
        isWithinWindow = minutesSinceScheduleStart % scheduleFrequencyMinutes == 0
        self.logger.debug(f'Is Within Scheduled Time Window: {isWithinWindow}')
        return isWithinWindow

    def hasReachedMaxActivePositions(self) -> bool:
        # Filter openPositions and workingOrders by strategyTag
        openPositionsByStrategy = {tag: pos for tag, pos in self.context.openPositions.items() if self.context.allPositions[pos].strategyTag == self.base.nameTag}
        workingOrdersByStrategy = {tag: order for tag, order in self.context.workingOrders.items() if order.strategyTag == self.base.nameTag}

        # Do not open any new positions if we have reached the maximum for this strategy
        return (len(openPositionsByStrategy) + len(workingOrdersByStrategy)) >= self.base.maxActivePositions

    def syncExpiryList(self, chain):
        # The list of expiry dates will change once a day (at most). See if we have already processed this list for the current date
        if self.context.Time.date() in self.expiryList:
            # Get the expiryList from the dictionary
            expiry = self.expiryList.get(self.context.Time.date())
        else:
            # Start the timer
            self.context.executionTimer.start("Alpha.Utils.Scanner -> syncExpiryList")

            # Set the DTE range (make sure values are not negative)
            minDte = max(0, self.base.dte - self.base.dteWindow)
            maxDte = max(0, self.base.dte)
            # Get the list of expiry dates, sorted in reverse order
            expiry = sorted(
                set(
                    [contract.Expiry for contract in chain if minDte <= (contract.Expiry.date() - self.context.Time.date()).days <= maxDte]
                ),
                reverse=True
            )
            # Only add the list to the dictionary if we found at least one expiry date
            if expiry:
                # Add the list to the dictionary
                self.expiryList[self.context.Time.date()] = expiry
            else:
                self.logger.debug(f"No expiry dates found in the chain! {self.context.Time.strftime('%Y-%m-%d %H:%M')}')}}")

            # Stop the timer
            self.context.executionTimer.stop("Alpha.Utils.Scanner -> syncExpiryList")

    def filterByExpiry(self, chain, expiry=None, computeGreeks=False):
        # Start the timer
        self.context.executionTimer.start("Alpha.Utils.Scanner -> filterByExpiry")

        # Check if the expiry date has been specified
        if expiry is not None:
            # Filter contracts based on the requested expiry date
            filteredChain = [
                contract for contract in chain if contract.Expiry.date() == expiry.date()
            ]
        else:
            # No filtering
            filteredChain = chain

        # Check if we need to compute the Greeks for every single contract (this is expensive!)
        # By default, the Greeks are only calculated while searching for the strike with the
        # requested delta, so there should be no need to set computeGreeks = True
        if computeGreeks:
            self.bsm.setGreeks(filteredChain)

        # Stop the timer
        self.context.executionTimer.stop("Alpha.Utils.Scanner -> filterByExpiry")

        # Return the filtered contracts
        return filteredChain

#region imports
from AlgorithmImports import *
#endregion

class Stats:
    def __init__(self):
        self._stats = {}

    def __setattr__(self, key, value):
        if key == '_stats':
            super().__setattr__(key, value)
        else:
            self._stats[key] = value

    def __getattr__(self, key):
        return self._stats.get(key, None)

    def __delattr__(self, key):
        if key in self._stats:
            del self._stats[key]
        else:
            raise AttributeError(f"No such attribute: {key}")
#region imports
from AlgorithmImports import *
#endregion

# Your New Python File
from .Scanner import Scanner
from .Order import Order
from .OrderBuilder import OrderBuilder
from .Stats import Stats
#region imports
from AlgorithmImports import *
#endregion


# Your New Python File
from .FPLModel import FPLModel
from .SPXic import SPXic
from .CCModel import CCModel
from .SPXButterfly import SPXButterfly
from .SPXCondor import SPXCondor
#region imports
from AlgorithmImports import *
from collections import deque
from scipy import stats
from numpy import mean, array
#endregion

# Indicator from https://www.satyland.com/atrlevels by Saty

# Use like this:
# 
# self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol        
# self.ATRLevels = ATRLevels("ATRLevels", length = 14)
# algorithm.RegisterIndicator(self.ticker, self.ATRLevels, Resolution.Daily)
# self.algorithm.WarmUpIndicator(self.ticker, self.ATRLevels, Resolution.Daily)


# // Set the appropriate timeframe based on trading mode
# timeframe_func() => 
#     timeframe = "D"
#     if trading_type == day_trading
#         timeframe := "D"
#     else if trading_type == multiday_trading
#         timeframe := "W"
#     else if trading_type == swing_trading
#         timeframe := "M"
#     else if trading_type == position_trading
#         timeframe := "3M"
#     else
#         timeframe := "D"

class ATRLevels(PythonIndicator):
    TriggerPercentage = 0.236
    MiddlePercentage = 0.618
    
    def __init__(self, name, length = 14):
        # default indicator definition
        super().__init__()
        self.Name = name
        self.Value = 0
        self.Time = datetime.min

        # set automatic warmup period + 1 day
        self.WarmUpPeriod = length + 1

        self.length = length
    
        self.ATR = AverageTrueRange(self.length)

        # Holds 2 values the current close and the previous day/period close.
        self.PreviousCloseQueue = deque(maxlen=2)

        # Indicator to hold the period close, high, low, open
        self.PeriodHigh = Identity('PeriodHigh')
        self.PeriodLow = Identity('PeriodLow')
        self.PeriodOpen = Identity('PeriodOpen')

    @property
    def IsReady(self) -> bool:
        return self.ATR.IsReady

    def Update(self, input) -> bool:
        # update all the indicators with the new data
        dataPoint = IndicatorDataPoint(input.Symbol, input.EndTime, input.Close)
        bar = TradeBar(input.Time, input.Symbol, input.Open, input.High, input.Low, input.Close, input.Volume)     
        ## Update SMA with data time and volume
        # symbolSMAv.Update(tuple.Index, tuple.volume)
        # symbolRSI.Update(tuple.Index, tuple.close)
        # symbolADX.Update(bar)
        # symbolATR.Update(bar)
        # symbolSMA.Update(tuple.Index, tuple.close)
        self.ATR.Update(bar)
        self.PreviousCloseQueue.appendleft(dataPoint)
        self.PeriodHigh.Update(input.Time, input.High)
        self.PeriodLow.Update(input.Time, input.Low)
        self.PeriodOpen.Update(input.Time, input.Open)
        
        if self.ATR.IsReady and len(self.PreviousCloseQueue) == 2:
            self.Time = input.Time
            self.Value = self.PreviousClose().Value

        return self.IsReady

    # Returns the previous close value of the period. 
    # @return [Float]
    def PreviousClose(self):
        if len(self.PreviousCloseQueue) == 1: return None
        return self.PreviousCloseQueue[0]

    # Bear level method. This is represented usually as a yellow line right under the close line.
    # @return [Float]
    def LowerTrigger(self):
        return self.PreviousClose().Value - (self.TriggerPercentage * self.ATR.Current.Value) # biggest value 1ATR
    
    # Lower Midrange level. This is under the lowerTrigger (yellow line) and above the -1ATR line(lowerATR)
    # @return [Float]
    def LowerMiddle(self):
        return self.PreviousClose().Value - (self.MiddlePercentage * self.ATR.Current.Value)

    # Lower -1ATR level.
    # @return [Float]
    def LowerATR(self):
        return self.PreviousClose().Value - self.ATR.Current.Value

    # Lower Extension level.
    # @return [Float]
    def LowerExtension(self):
        return self.LowerATR() - (self.TriggerPercentage * self.ATR.Current.Value)

    # Lower Midrange Extension level.
    # @return [Float]
    def LowerMiddleExtension(self):
        return self.LowerATR() - (self.MiddlePercentage * self.ATR.Current.Value)

    # Lower -2ATR level.
    # @return [Float]
    def Lower2ATR(self):
        return self.LowerATR() - self.ATR.Current.Value

    # Lower -2ATR Extension level.
    # @return [Float]
    def Lower2ATRExtension(self):
        return self.Lower2ATR() - (self.TriggerPercentage * self.ATR.Current.Value)
    
    # Lower -2ATR Midrange Extension level.
    # @return [Float]
    def Lower2ATRMiddleExtension(self):
        return self.Lower2ATR() - (self.MiddlePercentage * self.ATR.Current.Value)

    # Lower -3ATR level.
    # @return [Float]
    def Lower3ATR(self):
        return self.Lower2ATR() - self.ATR.Current.Value

    def BearLevels(self):
        return [
            self.LowerTrigger(), 
            self.LowerMiddle(), 
            self.LowerATR(), 
            self.LowerExtension(), 
            self.LowerMiddleExtension(), 
            self.Lower2ATR(), 
            self.Lower2ATRExtension(), 
            self.Lower2ATRMiddleExtension(),
            self.Lower3ATR()
        ]

    # Bull level method. This is represented usually as a blue line right over the close line.
    # @return [Float]
    def UpperTrigger(self):
        return self.PreviousClose().Value + (self.TriggerPercentage * self.ATR.Current.Value)  # biggest value 1ATR
    
    # Upper Midrange level.
    # @return [Float]
    def UpperMiddle(self):
        return self.PreviousClose().Value + (self.MiddlePercentage * self.ATR.Current.Value)
    
    # Upper 1ATR level.
    # @return [Float]
    def UpperATR(self):
        return self.PreviousClose().Value + self.ATR.Current.Value

    # Upper Extension level.
    # @return [Float]
    def UpperExtension(self):
        return self.UpperATR() + (self.TriggerPercentage * self.ATR.Current.Value)

    # Upper Midrange Extension level.
    # @return [Float]
    def UpperMiddleExtension(self):
        return self.UpperATR() + (self.MiddlePercentage * self.ATR.Current.Value)

    # Upper 2ATR level.
    def Upper2ATR(self):
        return self.UpperATR() + self.ATR.Current.Value
    
    # Upper 2ATR Extension level.
    # @return [Float]
    def Upper2ATRExtension(self):
        return self.Upper2ATR() + (self.TriggerPercentage * self.ATR.Current.Value)
    
    # Upper 2ATR Midrange Extension level.
    # @return [Float]
    def Upper2ATRMiddleExtension(self):
        return self.Upper2ATR() + (self.MiddlePercentage * self.ATR.Current.Value)

    # Upper 3ATR level.
    # @return [Float]
    def Upper3ATR(self):
        return self.Upper2ATR() + self.ATR.Current.Value

    def BullLevels(self):
        return [
            self.UpperTrigger(), 
            self.UpperMiddle(), 
            self.UpperATR(), 
            self.UpperExtension(), 
            self.UpperMiddleExtension(), 
            self.Upper2ATR(), 
            self.Upper2ATRExtension(), 
            self.Upper2ATRMiddleExtension(),
            self.Upper3ATR()
        ]

    def NextLevel(self, LevelNumber, bull = False, bear = False):
        dayOpen = self.PreviousClose().Value
        allLevels = [dayOpen] + self.BearLevels() + self.BullLevels()
        allLevels = sorted(allLevels, key = lambda x: x, reverse = False)
        bearLs = sorted(filter(lambda x: x <= dayOpen, allLevels), reverse = True)
        bullLs = list(filter(lambda x: x >= dayOpen, allLevels))

        if bull:
            return bullLs[LevelNumber]
        if bear:
            return bearLs[LevelNumber]
        return None

    def Range(self):
        return self.PeriodHigh.Current.Value - self.PeriodLow.Current.Value

    def PercentOfAtr(self):
        return (self.Range() / self.ATR.Current.Value) * 100

    def Warmup(self, history):
        for index, row in history.iterrows():
            self.Update(row)

    # Method to return a string with the bull and bear levels.
    # @return [String]
    def ToString(self):
        return "Bull Levels: [{}]; Bear Levels: [{}]".format(self.BullLevels(), self.BearLevels())
#region imports
from AlgorithmImports import *
from .ATRLevels import ATRLevels
#endregion


# Your New Python File
#region imports
from AlgorithmImports import *
#endregion

import math
from datetime import datetime, timedelta

"""
The GoogleSheetsData class reads data from the Google Sheets CSV link directly during live mode. In backtesting mode, you can use a
static CSV file saved in the local directory with the same format as the Google Sheets file.

The format should be like this:
    datetime,type,put_strike,call_strike,minimum_premium
    2023-12-23 14:00:00,Iron Condor,300,350,0.50
    2023-12-24 14:00:00,Bear Call Spread,0,360,0.60
    2023-12-25 14:00:00,Bull Put Spread,310,0,0.70

Replace the google_sheet_csv_link variable in the GetSource method with your actual Google Sheets CSV link.

Example for alpha model:

    class MyAlphaModel(AlphaModel):

        def Update(self, algorithm, data):
            if not data.ContainsKey('SPY_TradeInstructions'):
                return []

            trade_instructions = data['SPY_TradeInstructions']

            if trade_instructions is None:
                return []

            # Check if the current time is past the instructed time
            if algorithm.Time < trade_instructions.Time:
                return []

            # Use the trade_instructions data to generate insights
            type = trade_instructions.Type
            call_strike = trade_instructions.CallStrike
            put_strike = trade_instructions.PutStrike
            minimum_premium = trade_instructions.MinimumPremium

            insights = []

            if type == "Iron Condor":
                insights.extend(self.GenerateIronCondorInsights(algorithm, call_strike, put_strike, minimum_premium))
            elif type == "Bear Call Spread":
                insights.extend(self.GenerateBearCallSpreadInsights(algorithm, call_strike, minimum_premium))
            elif type == "Bull Put Spread":
                insights.extend(self.GenerateBullPutSpreadInsights(algorithm, put_strike, minimum_premium))

            return insights
"""


class GoogleSheetsData(PythonData):
    def GetSource(self, config, date, isLiveMode):
        google_sheet_csv_link = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vS9oNUoYqY-u0WnLuJRCb8pSuQKcLStK8RaTfs5Cm9j6iiYNpx82iJuAc3D32zytXA4EiosfxjWKyJp/pub?gid=509927026&single=true&output=csv'
        if isLiveMode:
            return SubscriptionDataSource(google_sheet_csv_link, SubscriptionTransportMedium.Streaming)
        else:
            return SubscriptionDataSource(google_sheet_csv_link, SubscriptionTransportMedium.RemoteFile)
        
        # if isLiveMode:
        #     # Replace the link below with your Google Sheets CSV link
        #     google_sheet_csv_link = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vS9oNUoYqY-u0WnLuJRCb8pSuQKcLStK8RaTfs5Cm9j6iiYNpx82iJuAc3D32zytXA4EiosfxjWKyJp/pub?gid=509927026&single=true&output=csv'
        #     return SubscriptionDataSource(google_sheet_csv_link, SubscriptionTransportMedium.RemoteFile)

        # # In backtesting, you can use a static CSV file saved in the local directory
        # return SubscriptionDataSource("trade_instructions.csv", SubscriptionTransportMedium.LocalFile)

    def Reader(self, config, line, date, isLiveMode):
        if not line.strip():
            return None

        columns = line.split(',')

        if columns[0] == 'datetime':
            return None

        trade = GoogleSheetsData()
        trade.Symbol = config.Symbol
        trade.Value = float(columns[2]) or float(columns[3])

        # Parse the datetime and adjust the timezone
        trade_time = datetime.strptime(columns[0], "%Y-%m-%d %H:%M:%S") - timedelta(hours=7)

        # Round up the minute to the nearest 5 minutes
        minute = 5 * math.ceil(trade_time.minute / 5)
        # If the minute is 60, set it to 0 and add 1 hour
        if minute == 60:
            trade_time = trade_time.replace(minute=0, hour=trade_time.hour+1)
        else:
            trade_time = trade_time.replace(minute=minute)

        trade.Time = trade_time
        # trade.EndTime = trade.Time + timedelta(hours=4)
        trade["Type"] = columns[1]
        trade["PutStrike"] = float(columns[2])
        trade["CallStrike"] = float(columns[3])
        trade["MinimumPremium"] = float(columns[4])

        return trade
#region imports
from AlgorithmImports import *
#endregion

from .Base import Base


class AutoExecutionModel(Base):
    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
from AlgorithmImports import *

from Tools import ContractUtils, Logger
from Execution.Utils import MarketOrderHandler, LimitOrderHandler
"""
"""

class Base(ExecutionModel):
    DEFAULT_PARAMETERS = {
        # Retry decrease/increase percentage. Each time we try and get a fill we are going to decrease the limit price
        # by this percentage.
        "retryChangePct": 1.0,
        # Minimum price percentage accepted as limit price. If the limit price set is 0.5 and this value is 0.8 then
        # the minimum price accepted will be 0.4
        "minPricePct": 0.7,
        # The limit order price initial adjustmnet. This will add some leeway to the limit order price so we can try and get
        # some more favorable price for the user than the algo set price. So if we set this to 0.1 (10%) and our limit price
        # is 0.5 then we will try and fill the order at 0.55 first.
        "orderAdjustmentPct": -0.2,
        # The increment we are going to use to adjust the limit price. This is used to 
        # properly adjust the price for SPX options. If the limit price is 0.5 and this
        # value is 0.01 then we are going to try and fill the order at 0.51, 0.52, 0.53, etc.
        "adjustmentIncrement": None, # 0.01,
        # Speed of fill. Option taken from https://optionalpha.com/blog/smartpricing-released. 
        # Can be: "Normal", "Fast", "Patient"
        # "Normal" will retry every 3 minutes, "Fast" every 1 minute, "Patient" every 5 minutes.
        "speedOfFill": "Fast",
        # maxRetries is the maximum number of retries we are going to do to try 
        # and get a fill. This is calculated based on the speedOfFill and this 
        # value is just for reference.
        "maxRetries": 10,
    }

    def __init__(self, context):
        self.context = context
        # Calculate maxRetries based on speedOfFill
        speedOfFill = self.parameter("speedOfFill")
        if speedOfFill == "Patient":
            self.maxRetries = 7
        elif speedOfFill == "Normal":
            self.maxRetries = 5
        elif speedOfFill == "Fast":
            self.maxRetries = 3
        else:
            raise ValueError("Invalid speedOfFill value")

        self.targetsCollection = PortfolioTargetCollection()
        self.contractUtils = ContractUtils(context)
        # Set the logger
        self.logger = Logger(context, className=type(self).__name__, logLevel=context.logLevel)
        self.marketOrderHandler = MarketOrderHandler(context, self)
        self.limitOrderHandler = LimitOrderHandler(context, self)
        self.logger.debug(f"{self.__class__.__name__} -> __init__")
        # Gets or sets the maximum spread compare to current price in percentage.
        # self.acceptingSpreadPercent = Math.Abs(acceptingSpreadPercent)
        # self.executionTimeThreshold = timedelta(minutes=10)
        # self.openExecutedOrders = {}

        self.context.structure.AddConfiguration(parent=self, **self.getMergedParameters())

    @classmethod
    def getMergedParameters(cls):
        # Merge the DEFAULT_PARAMETERS from both classes
        return {**cls.DEFAULT_PARAMETERS, **getattr(cls, "PARAMETERS", {})}

    @classmethod
    def parameter(cls, key, default=None):
        return cls.getMergedParameters().get(key, default)

    def Execute(self, algorithm, targets):
        self.context.executionTimer.start('Execution.Base -> Execute')

        # Use this section to check if a target is in the workingOrder dict
        self.targetsCollection.AddRange(targets)
        self.logger.debug(f"{self.__class__.__name__} -> Execute -> targets: {targets}")
        self.logger.debug(f"{self.__class__.__name__} -> Execute -> targets count: {len(targets)}")
        self.logger.debug(f"{self.__class__.__name__} -> Execute -> workingOrders: {self.context.workingOrders}")
        self.logger.debug(f"{self.__class__.__name__} -> Execute -> allPositions: {self.context.allPositions}")
        # Check if the workingOrders are still OK to execute
        self.context.structure.checkOpenPositions()
        self.logger.debug(f"{self.__class__.__name__} -> Execute -> checkOpenPositions")
        for order in list(self.context.workingOrders.values()):
            position = self.context.allPositions[order.orderId]

            useLimitOrders = order.useLimitOrder
            useMarketOrders = not useLimitOrders
            self.logger.debug(f"Processing order: {order.orderId}")
            self.logger.debug(f"Order details: {order}")
            self.logger.debug(f"Position details: {position}")
            self.logger.debug(f"Use Limit Orders: {useLimitOrders}")
            self.logger.debug(f"Use Market Orders: {useMarketOrders}")
            if useMarketOrders:
                self.marketOrderHandler.call(position, order)
            elif useLimitOrders:
                self.limitOrderHandler.call(position, order)

        # if not self.targetsCollection.IsEmpty:
        #     for target in targets:
        #         order = Helper().findIn(
        #             self.context.workingOrders.values(),
        #             lambda v: any(t == target for t in v.targets)
        #         )
        #         orders[order.orderId] = order

        #     for order in orders.values():
        #         position = self.context.allPositions[order.orderId]
        #         useLimitOrders = order.useLimitOrder
        #         useMarketOrders = not useLimitOrders

        #         if useMarketOrders:
        #             self.executeMarketOrder(position, order)
        #         elif useLimitOrders:
        #             self.executeLimitOrder(position, order)

        self.targetsCollection.ClearFulfilled(algorithm)
        # Update the charts after execution
        self.context.charting.updateCharts()

        self.context.executionTimer.stop('Execution.Base -> Execute')


#region imports
from AlgorithmImports import *
#endregion

from .Base import Base

class SPXExecutionModel(Base):
    PARAMETERS = {
        # "orderAdjustmentPct": None,
        # The increment we are going to use to adjust the limit price. This is used to 
        # properly adjust the price for SPX options. If the limit price is 0.5 and this
        # value is 0.01 then we are going to try and fill the order at 0.51, 0.52, 0.53, etc.
        "adjustmentIncrement": 0.05,
        # Speed of fill. Option taken from https://optionalpha.com/blog/smartpricing-released. 
        # Can be: "Normal", "Fast", "Patient"
        # "Normal" will retry every 3 minutes, "Fast" every 1 minute, "Patient" every 5 minutes.
        "speedOfFill": "Fast",
        # maxRetries is the maximum number of retries we are going to do to try 
        # and get a fill. This is calculated based on the speedOfFill and this 
        # value is just for reference.
        "maxRetries": 10,
    }
    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
from AlgorithmImports import *

"""
Started discussion on this here: https://chat.openai.com/chat/b5be32bf-850a-44ba-80fc-44f79a7df763
Use like this in main.py file:

percent_of_spread = 0.5
timeout = timedelta(minutes=2)
self.SetExecution(SmartPricingExecutionModel(percent_of_spread, timeout))
"""
class SmartPricingExecutionModel(ExecutionModel):
    def __init__(self, percent_of_spread, timeout):
        self.percent_of_spread = percent_of_spread
        self.timeout = timeout
        self.order_tickets = dict()

    def Execute(self, algorithm, targets):
        for target in targets:
            symbol = target.Symbol
            quantity = target.Quantity

            # If an order already exists for the symbol, skip
            if symbol in self.order_tickets:
                continue

            # Get the bid-ask spread and apply the user-defined percentage
            security = algorithm.Securities[symbol]
            if security.BidPrice != 0 and security.AskPrice != 0:
                spread = security.AskPrice - security.BidPrice
                adjusted_spread = spread * self.percent_of_spread

                if quantity > 0:
                    limit_price = security.BidPrice + adjusted_spread
                else:
                    limit_price = security.AskPrice - adjusted_spread

                # Submit the limit order with the calculated price
                ticket = algorithm.LimitOrder(symbol, quantity, limit_price)
                self.order_tickets[symbol] = ticket

                # Set the order expiration
                expiration = algorithm.UtcTime + self.timeout
                # ticket.Update(new UpdateOrderFields { TimeInForce = TimeInForce.GoodTilDate(expiration) })

    def OnOrderEvent(self, algorithm, order_event):
        if order_event.Status.IsClosed():
            order = algorithm.Transactions.GetOrderById(order_event.OrderId)
            symbol = order.Symbol
            if symbol in self.order_tickets:
                del self.order_tickets[symbol]
#region imports
from AlgorithmImports import *
#endregion

from Tools import ContractUtils, Logger, Underlying


# Your New Python File
class LimitOrderHandler:
    def __init__(self, context, base):
        self.context = context
        self.contractUtils = ContractUtils(context)
        self.base = base
        # Set the logger
        self.logger = Logger(context, className=type(self).__name__, logLevel=context.logLevel)

    def call(self, position, order):
        # Start the timer
        self.context.executionTimer.start()

        # Get the context
        context = self.context

        # Get the Limit order details
        # Get the order type: open|close
        orderType = order.orderType

        # This updates prices and stats for the order
        position.updateOrderStats(context, orderType)
        # This updates the stats for the position
        position.updateStats(context, orderType)
        execOrder = position[f"{orderType}Order"]

        ticket = None
        orderTransactionIds = execOrder.transactionIds
        self.logger.debug(f"orderTransactionIds: {orderTransactionIds}")
        self.logger.debug(f"order.lastRetry: {order.lastRetry}")
        self.logger.debug(f"self.sinceLastRetry(context, order, timedelta(minutes = 1)): {self.sinceLastRetry(context, order, timedelta(minutes = 1))}")

        # Exit if we are not at the right scheduled interval
        if orderTransactionIds and (order.lastRetry is None or self.sinceLastRetry(context, order, timedelta(minutes = 1))):
            """
            IMPORTANT!!:
            Why do we cancel?
            If we update the ticket with the new price then we risk execution while updating the price of the rest of the combo order causing discrepancies.
            """
            for id in orderTransactionIds:
                ticket = context.Transactions.GetOrderTicket(id)
                ticket.Cancel('Cancelled trade and trying with new prices')
            # store when we last canceled/retried and check with current time if like 2-3 minutes passed before we retry again.
            self.makeLimitOrder(position, order, retry = True)
            # NOTE: If combo limit orders will execute limit orders instead of market orders then let's use this method.
            # self.updateComboLimitOrder(position, orderTransactionIds)
        elif not orderTransactionIds:
            self.makeLimitOrder(position, order)

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

    def makeLimitOrder(self, position, order, retry = False):
        context = self.context
        # Get the Limit order details
        # Get the order type: open|close
        orderType = order.orderType
        limitOrderPrice = self.limitOrderPrice(order)
        execOrder = position[f"{orderType}Order"]

        # Keep track of the midPrices of this order for faster debugging
        execOrder.priceProgressList.append(round(execOrder.midPrice, 2))
        orderTag = position.orderTag
        # Get the contracts
        contracts = [v.contract for v in position.legs]
        # Get the order quantity
        orderQuantity = position.orderQuantity
        # Sign of the order: open -> 1 (use orderSide as is),  close -> -1 (reverse the orderSide)
        orderSign = 2 * int(orderType == "open") - 1
        # Get the order sides
        orderSides = np.array([c.contractSide for c in position.legs])

        # Define the legs of the combo order
        legs = []
        isComboOrder = len(contracts) > 1

        # Log the parameters used to validate the order
        self.logger.debug(f"Executing Limit Order to {orderType} the position:")
        self.logger.debug(f" - orderType: {orderType}")
        self.logger.debug(f" - orderTag: {orderTag}")
        self.logger.debug(f" - underlyingPrice: {Underlying(context, position.underlyingSymbol()).Price()}")
        self.logger.debug(f" - strikes: {[c.Strike for c in contracts]}")
        self.logger.debug(f" - orderQuantity: {orderQuantity}")
        self.logger.debug(f" - midPrice: {execOrder.midPrice}  (limitOrderPrice: {limitOrderPrice})")
        self.logger.debug(f" - bidAskSpread: {execOrder.bidAskSpread}")

        # Calculate the adjustment value based on the difference between the limit price and the total midPrice
        # TODO: this might have to be changed if we start buying options instead of selling for premium.
        if orderType == "close":
            adjustmentValue = self.calculateAdjustmentValueBought(
                execOrder=execOrder,
                limitOrderPrice=limitOrderPrice, 
                retries=order.fillRetries, 
                nrContracts=len(contracts)
            )
        else:
            adjustmentValue = self.calculateAdjustmentValueSold(
                execOrder=execOrder,
                limitOrderPrice=limitOrderPrice, 
                retries=order.fillRetries, 
                nrContracts=len(contracts)
            )
        # IMPORTANT!! Because ComboLimitOrder right now still executes market orders we should not use it. We need to use ComboLegLimitOrder and that will work.
        for n, contract in enumerate(contracts):
            # Set the order side: -1 -> Sell, +1 -> Buy
            orderSide = orderSign * orderSides[n]
            if orderSide != 0:
                newLimitPrice = self.contractUtils.midPrice(contract) + adjustmentValue if orderSide == -1 else self.contractUtils.midPrice(contract) - adjustmentValue
                # round the price or we get an error like:
                # Adjust the limit price to meet brokerage precision requirements
                increment = self.base.adjustmentIncrement if self.base.adjustmentIncrement is not None else 0.05
                newLimitPrice = round(newLimitPrice / increment) * increment
                newLimitPrice = round(newLimitPrice, 1)  # Ensure the price is rounded to two decimal places
                newLimitPrice = max(newLimitPrice, increment) # make sure the price is never 0. At least the increment.
                self.logger.info(f"{orderType.upper()} {orderQuantity} {orderTag}, {contract.Symbol}, newLimitPrice: {newLimitPrice}")

                if isComboOrder:
                    legs.append(Leg.Create(contract.Symbol, orderSide, newLimitPrice))
                else:
                    newTicket = context.LimitOrder(contract.Symbol, orderQuantity, newLimitPrice, tag=orderTag)
                    execOrder.transactionIds = [newTicket.OrderId]

        log_message = f"{orderType.upper()} {orderQuantity} {orderTag}, "
        log_message += f"{[c.Strike for c in contracts]} @ Mid: {round(execOrder.midPrice, 2)}, "
        log_message += f"NewLimit: {round(sum([l.OrderPrice * l.Quantity for l in legs]), 2)}, "
        log_message += f"Limit: {round(limitOrderPrice, 2)}, "
        log_message += f"DTTM: {execOrder.limitOrderExpiryDttm}, "
        log_message += f"Spread: ${round(execOrder.bidAskSpread, 2)}, "
        log_message += f"Bid & Ask: {[(round(self.contractUtils.bidPrice(c), 2), round(self.contractUtils.askPrice(c),2)) for c in contracts]}, "
        log_message += f"Volume: {[self.contractUtils.volume(c) for c in contracts]}, "
        log_message += f"OpenInterest: {[self.contractUtils.openInterest(c) for c in contracts]}"
        
        if orderType.lower() == 'close':
            log_message += f", Reason: {position.closeReason}"
        # To limit logs just log every 25 minutes
        self.logger.info(log_message)

        ### for contract in contracts
        if isComboOrder:
            # Execute by using a multi leg order if we have multiple sides.
            newTicket = context.ComboLegLimitOrder(legs, orderQuantity, tag=orderTag)
            execOrder.transactionIds = [t.OrderId for t in newTicket]
        # Store the last retry on this order. This is not ideal but the only way to handle combo limit orders on QC as the comboLimitOrder and all the others
        # as soon as you update one leg it will execute and mess it up.
        if retry:
            order.lastRetry = context.Time
            order.fillRetries += 1 # increment the number of fill tries

    def limitOrderPrice(self, order):
        orderType = order.orderType
        limitOrderPrice = order.limitOrderPrice
        # Just use a default limit price that is supposed to be the smallest prossible.
        # The limit order price of 0 can happen if the trade is worthless.
        if limitOrderPrice == 0 and orderType == 'close':
            limitOrderPrice = 0.05

        return limitOrderPrice

    def sinceLastRetry(self, context, order, frequency = timedelta(minutes = 3)):
        if order.lastRetry is None: return True

        timeSinceLastRetry = context.Time - order.lastRetry
        minutesSinceLastRetry = timedelta(minutes = round(timeSinceLastRetry.seconds / 60))
        return minutesSinceLastRetry % frequency == timedelta(minutes=0)

    def calculateAdjustmentValueSold(self, execOrder, limitOrderPrice, retries=0, nrContracts=1):
        if self.base.orderAdjustmentPct is None and self.base.adjustmentIncrement is None:
            raise ValueError("orderAdjustmentPct or adjustmentIncrement must be set in the parameters")

        # Adjust the limitOrderPrice
        limitOrderPrice += self.base.orderAdjustmentPct * limitOrderPrice # Increase the price by orderAdjustmentPct

        min_price = self.base.minPricePct * limitOrderPrice # Minimum allowed price is % of limitOrderPrice

        # Calculate the range and step
        if self.base.adjustmentIncrement is None:
            # Calculate the step based on the bidAskSpread and the number of retries
            step = execOrder.bidAskSpread / self.base.maxRetries
        else:
            step = self.base.adjustmentIncrement

        # Start with the preferred price
        target_price = execOrder.midPrice + step

        # If we have retries, adjust the target price accordingly
        if retries > 0:
            target_price -= retries * step

        # Ensure the target price does not fall below the minimum limit
        if target_price < min_price:
            target_price = min_price

        # Round the target price to the nearest multiple of adjustmentIncrement
        target_price = round(target_price / step) * step

        # Calculate the adjustment value
        adjustment_value = (target_price - execOrder.midPrice) / nrContracts

        return adjustment_value

    def calculateAdjustmentValueBought(self, execOrder, limitOrderPrice, retries=0, nrContracts=1):
        if self.base.orderAdjustmentPct is None and self.base.adjustmentIncrement is None:
            raise ValueError("orderAdjustmentPct or adjustmentIncrement must be set in the parameters")

        # Adjust the limitOrderPrice
        limitOrderPrice += self.base.orderAdjustmentPct * limitOrderPrice # Increase the price by orderAdjustmentPct

        increment = self.base.retryChangePct * limitOrderPrice  # Increment value for each retry
        max_price = self.base.minPricePct * limitOrderPrice # Maximum allowed price is % of limitOrderPrice

        # Start with the preferred price
        target_price = max_price

        # If we have retries, increment the target price accordingly
        if retries > 0:
            target_price += retries * increment

        # Ensure the target price does not exceed the maximum limit
        if target_price > limitOrderPrice:
            target_price = limitOrderPrice

        # Calculate the range and step
        if self.base.adjustmentIncrement is None:
            # Calculate the step based on the bidAskSpread and the number of retries
            step = execOrder.bidAskSpread / self.base.maxRetries
        else:
            step = self.base.adjustmentIncrement

        # Round the target price to the nearest multiple of adjustmentIncrement
        target_price = round(target_price / step) * step

        # Calculate the adjustment value
        adjustment_value = (target_price - execOrder.midPrice) / nrContracts

        return adjustment_value
    
    """
    def updateComboLimitOrder(self, position, orderTransactionIds):
        context = self.context

        for id in orderTransactionIds:
            ticket = context.Transactions.GetOrderTicket(id)
            # store when we last canceled/retried and check with current time if like 2-3 minutes passed before we retry again.
            leg = next((leg for leg in position.legs if ticket.Symbol == leg.symbol), None)            contract = leg.contract
            # To update the limit price of the combo order, you only need to update the limit price of one of the leg orders.
            # The Update method returns an OrderResponse to signal the success or failure of the update request.
            if ticket and ticket.Status is not OrderStatus.Filled:
                newLimitPrice = self.contractUtils.midPrice(contract) + 0.1 if leg.isSold else self.contractUtils.midPrice(contract) - 0.1

                update_settings = UpdateOrderFields()
                update_settings.LimitPrice = newLimitPrice
                response = ticket.Update(update_settings)
                # Check if the update was successful
                if response.IsSuccess:
                    self.logger.debug(f"Order updated successfully for {ticket.Symbol}")
    """
    
#region imports
from AlgorithmImports import *
#endregion

from Tools import ContractUtils, Logger, Underlying

# Your New Python File
class MarketOrderHandler:
    def __init__(self, context, base):
        self.context = context
        self.base = base
        self.contractUtils = ContractUtils(context)
        # Set the logger
        self.logger = Logger(context, className=type(self).__name__, logLevel=context.logLevel)

    def call(self, position, order):
        # Start the timer
        self.context.executionTimer.start()

        # Get the context
        context = self.context
        orderTag = position.orderTag
        orderQuantity = position.orderQuantity

        orderType = order.orderType
        contracts = [v.contract for v in position.legs]
        orderSides = [v.contractSide for v in position.legs]
        # orderSides = np.array([c.contractSide for c in position.legs])
        bidAskSpread = sum(list(map(self.contractUtils.bidAskSpread, contracts)))
        midPrice = sum(side * self.contractUtils.midPrice(contract) for side, contract in zip(orderSides, contracts))
        underlying = Underlying(context, position.underlyingSymbol())
        orderSign = 2 * int(orderType == "open") - 1
        execOrder = position[f"{orderType}Order"]
        execOrder.midPrice = midPrice

        # This updates prices and stats for the order
        position.updateOrderStats(context, orderType)
        # This updates the stats for the position
        position.updateStats(context, orderType)

        # Keep track of the midPrices of this order for faster debugging
        execOrder.priceProgressList.append(round(midPrice, 2))

        isComboOrder = len(position.legs) > 1
        legs = []
        # Loop through all contracts
        for contract in position.legs:
            # Get the order side
            orderSide = contract.contractSide * orderSign
            # Get the order quantity
            quantity = contract.quantity
            # Get the contract symbol
            symbol = contract.symbol
            # Get the contract object
            security = context.Securities[symbol]
            # get the target
            target = next(t for t in order.targets if t.Symbol == symbol)
            # calculate remaining quantity to be ordered
            # quantity = OrderSizing.GetUnorderedQuantity(context, target, security)

            self.logger.debug(f"{orderType} contract {symbol}:")
            self.logger.debug(f" - orderSide: {orderSide}")
            self.logger.debug(f" - quantity: {quantity}")
            self.logger.debug(f" - orderTag: {orderTag}")

            if orderSide != 0:
                if isComboOrder:
                    # If we are doing market orders, we need to create the legs of the combo order
                    legs.append(Leg.Create(symbol, orderSide))
                else:
                    # Send the Market order (asynchronous = True -> does not block the execution in case of partial fills)
                    context.MarketOrder(
                        symbol,
                        orderSide * quantity,
                        asynchronous=True,
                        tag=orderTag
                    )
        ### Loop through all contracts

        # Log the parameters used to validate the order
        log_message = f"{orderType.upper()} {orderQuantity} {orderTag}, "
        log_message += f"{[c.Strike for c in contracts]} @ Mid: {round(midPrice, 2)}"
        if orderType.lower() == 'close':
            log_message += f", Reason: {position.closeReason}"
        self.logger.info(log_message)

        self.logger.debug(f"Executing Market Order to {orderType} the position:")
        self.logger.debug(f" - orderType: {orderType}")
        self.logger.debug(f" - orderTag: {orderTag}")
        self.logger.debug(f" - underlyingPrice: {underlying.Price()}")
        self.logger.debug(f" - strikes: {[c.Strike for c in contracts]}")
        self.logger.debug(f" - orderQuantity: {orderQuantity}")
        self.logger.debug(f" - midPrice: {midPrice}")
        self.logger.debug(f" - bidAskSpread: {bidAskSpread}")

        # Execute only if we have multiple legs (sides) per order
        if (
            len(legs) > 0
            # Validate the bid-ask spread to make sure it's not too wide
            and not (position.strategyParam("validateBidAskSpread") and abs(bidAskSpread) > position.strategyParam("bidAskSpreadRatio")*abs(midPrice))
        ):
            context.ComboMarketOrder(
                legs,
                orderQuantity,
                asynchronous=True,
                tag=orderTag
            )

        # Stop the timer
        self.context.executionTimer.stop()
#region imports
from AlgorithmImports import *
#endregion


from .LimitOrderHandler import LimitOrderHandler
from .MarketOrderHandler import MarketOrderHandler
#region imports
from AlgorithmImports import *
#endregion


# Your New Python File
from .AutoExecutionModel import AutoExecutionModel
from .SmartPricingExecutionModel import SmartPricingExecutionModel
from .SPXExecutionModel import SPXExecutionModel
#region imports
from AlgorithmImports import *
#endregion


class AlwaysBuyingPowerModel(BuyingPowerModel):
    def __init__(self, context):
        super().__init__()
        self.context = context

    def HasSufficientBuyingPowerForOrder(self, parameters):
        # custom behavior: this model will assume that there is always enough buying power
        hasSufficientBuyingPowerForOrderResult = HasSufficientBuyingPowerForOrderResult(True)
        self.context.logger.debug(f"CustomBuyingPowerModel: {hasSufficientBuyingPowerForOrderResult.IsSufficient}")

        return hasSufficientBuyingPowerForOrderResult


#region imports
from AlgorithmImports import *
#endregion
import numpy as np


# Custom Fill model based on Beta distribution:
#  - Orders are filled based on a Beta distribution  skewed towards the mid-price with Sigma = bidAskSpread/6 (-> 99% fills within the bid-ask spread)
class BetaFillModel(ImmediateFillModel):

    # Initialize Random Number generator with a fixed seed (for replicability)
    random = np.random.RandomState(1234)

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

    def MarketFill(self, asset, order):
        # Start the timer
        self.context.executionTimer.start()

        # Get the random number generator
        random = BetaFillModel.random
        # Compute the Bid-Ask spread
        bidAskSpread = abs(asset.AskPrice - asset.BidPrice)
        # Compute the Mid-Price
        midPrice = 0.5 * (asset.AskPrice + asset.BidPrice)
        # Call the parent method
        fill = super().MarketFill(asset, order)
        # Setting the parameters of the Beta distribution:
        # - The shape parameters (alpha and beta) are chosen such that the fill is "reasonably close" to the mid-price about 96% of the times
        # - How close -> The fill price is within 15% of half the bid-Ask spread
        if order.Direction == OrderDirection.Sell:
            # Beta distribution in the range [Bid-Price, Mid-Price], skewed towards the Mid-Price
            # - Fill price is within the range [Mid-Price - 0.15*bidAskSpread/2, Mid-Price] with about 96% probability
            offset = asset.BidPrice
            alpha = 20
            beta = 1
        else:
            # Beta distribution in the range [Mid-Price, Ask-Price], skewed towards the Mid-Price
            # - Fill price is within the range [Mid-Price, Mid-Price + 0.15*bidAskSpread/2] with about 96% probability
            offset = midPrice
            alpha = 1
            beta = 20
        # Range (width) of the Beta distribution
        range = bidAskSpread / 2.0
        # Compute the new fillPrice (centered around the midPrice)
        fillPrice = round(offset + range * random.beta(alpha, beta), 2)
        # Update the FillPrice attribute
        fill.FillPrice = fillPrice
        # Stop the timer
        self.context.executionTimer.stop()
        # Return the fill
        return fill
#region imports
from AlgorithmImports import *
#endregion

import re
import numpy as np
from Tools import Logger, Helper


"""
Details about order types:
/// New order pre-submission to the order processor (0)
New = 0,
/// Order submitted to the market (1)
Submitted = 1,
/// Partially filled, In Market Order (2)
PartiallyFilled = 2,
/// Completed, Filled, In Market Order (3)
Filled = 3,
/// Order cancelled before it was filled (5)
Canceled = 5,
/// No Order State Yet (6)
None = 6,
/// Order invalidated before it hit the market (e.g. insufficient capital) (7)
Invalid = 7,
/// Order waiting for confirmation of cancellation (6)
CancelPending = 8,
/// Order update submitted to the market (9)
UpdateSubmitted = 9
"""


class HandleOrderEvents:
    def __init__(self, context, orderEvent):
        self.context = context
        self.orderEvent = orderEvent
        self.logger = Logger(self.context, className=type(self.context).__name__, logLevel=self.context.logLevel)

    # section: handle order events from main.py
    def Call(self):
        # Get the context
        context = self.context
        orderEvent = self.orderEvent

        # Start the timer
        context.executionTimer.start()

        # Process only Fill events
        if not (orderEvent.Status == OrderStatus.Filled or orderEvent.Status == OrderStatus.PartiallyFilled):
            return

        if(orderEvent.IsAssignment):
            # TODO: Liquidate the assigned position.
            #  Eventually figure out which open position it belongs to and close that position.
            return

        # Get the orderEvent id
        orderEventId = orderEvent.OrderId
        # Retrieve the order associated to this events
        order = context.Transactions.GetOrderById(orderEventId)
        # Get the order tag. Remove any warning text that might have been added in case of Fills at Stale Price
        orderTag = re.sub(" - Warning.*", "", order.Tag)

        # TODO: Additionally check for OTM Underlying order.Tag that would mean it expired worthless.
        # if orderEvent.FillPrice == 0.0:
        #     position = next((position for position in context.allPositions if any(leg.symbol == orderEvent.Symbol for leg in position.legs)), None)
        #     context.workingOrders.pop(position.orderTag)

        # Get the working order (if available)
        workingOrder = context.workingOrders.get(orderTag)
        # Exit if this order tag is not in the list of open orders.
        if workingOrder == None:
            return

        # Get the position from the openPositions
        openPosition = context.openPositions.get(orderTag)
        if openPosition is None:
            return
        # Retrieved the book position (this it the full entry inside allPositions that will be converted into a CSV record)
        # bookPosition = context.allPositions[orderId]
        bookPosition = context.allPositions[openPosition]

        contractInfo = Helper().findIn(
            bookPosition.legs,
            lambda c: c.symbol == orderEvent.Symbol
        )
        # Exit if we couldn't find the contract info.
        if contractInfo == None:
            return

        # Get the order id and expiryStr value for the contract
        orderId = bookPosition.orderId # contractInfo["orderId"]
        positionKey = bookPosition.orderTag # contractInfo["positionKey"]
        expiryStr = contractInfo.expiry # contractInfo["expiryStr"]
        orderType = workingOrder.orderType # contractInfo["orderType"]

        # Log the order event
        self.logger.debug(f" -> Processing order id {orderId} (orderTag: {orderTag}  -  orderType: {orderType}  -  Expiry: {expiryStr})")

        # Get the contract associated to this order event
        contract = contractInfo.contract # openPosition["contractDictionary"][orderEvent.Symbol]
        # Get the description associated with this contract
        contractDesc = contractInfo.key # openPosition["contractSideDesc"][orderEvent.Symbol]
        # Get the quantity used to open the position
        positionQuantity = bookPosition.orderQuantity # openPosition["orderQuantity"]
        # Get the side of each leg (-n -> Short, +n -> Long)
        contractSides = np.array([c.contractSide for c in bookPosition.legs]) # np.array(openPosition["sides"])
        # Leg Quantity
        legQuantity = abs(bookPosition.contractSide[orderEvent.Symbol])
        # Total legs quantity in the whole position
        Nlegs = sum(abs(contractSides))

        # get the position order block
        execOrder = bookPosition[f"{orderType}Order"]

        # Check if the contract was filled at a stale price (Warnings in the orderTag)
        if re.search(" - Warning.*", order.Tag):
            self.logger.warning(order.Tag)
            execOrder.stalePrice = True
            bookPosition[f"{orderType}StalePrice"] = True

        # Add the order to the list of openPositions orders (only if this is the first time the order is filled  - in case of partial fills)
        # if contractInfo["fills"] == 0:
        #     openPosition[f"{orderType}Order"]["orders"].append(order)

        # Update the number of filled contracts associated with this order
        workingOrder.fills += abs(orderEvent.FillQuantity)

        # Remove this order entry from the self.workingOrders[orderTag] dictionary if it has been fully filled
        # if workingOrder.fills == legQuantity * positionQuantity:
        #     removedOrder = context.workingOrders.pop(orderTag)
        #     # Update the stats of the given contract inside the bookPosition (reverse the sign of the FillQuantity: Sell -> credit, Buy -> debit)
        #     bookPosition.updateContractStats(openPosition, contract, orderType = orderType, fillPrice = - np.sign(orderEvent.FillQuantity) * orderEvent.FillPrice)

        # Update the counter of positions that have been filled
        execOrder.fills += abs(orderEvent.FillQuantity)
        execOrder.fillPrice -= np.sign(orderEvent.FillQuantity) * orderEvent.FillPrice
        # Get the total amount of the transaction
        transactionAmt = orderEvent.FillQuantity * orderEvent.FillPrice * 100

        # Check if this is a fill order for an entry position
        if orderType == "open":
            # Update the openPremium field to include the current transaction (use "-=" to reverse the side of the transaction: Short -> credit, Long -> debit)
            bookPosition.openPremium -= transactionAmt
        else: # This is an order for the exit position
            # Update the closePremium field to include the current transaction  (use "-=" to reverse the side of the transaction: Sell -> credit, Buy -> debit)
            bookPosition.closePremium -= transactionAmt

        # Check if all legs have been filled
        if execOrder.fills == Nlegs*positionQuantity:
            execOrder.filled = True
            bookPosition.updateOrderStats(context, orderType)
            # Remove the working order now that it has been filled
            context.workingOrders.pop(orderTag)
            # Set the time when the full order was filled
            bookPosition[orderType + "FilledDttm"] = context.Time
            # Record the order mid price
            bookPosition[orderType + "OrderMidPrice"] = execOrder.midPrice

            # All of this for the logger.info
            orderTypeUpper = orderType.upper()
            premium = round(bookPosition[f'{orderType}Premium'], 2)
            fillPrice = round(execOrder.fillPrice, 2)
            message = f"  >>>  {orderTypeUpper}: {orderTag}, Premium: ${premium} @ ${fillPrice}"
            if orderTypeUpper == "CLOSE":
                PnL = round(bookPosition.PnL, 2)
                percentage = round(bookPosition.PnL / bookPosition.openPremium * 100, 2)
                message += f"; P&L: ${PnL} ({percentage}%)"
            
            self.logger.info(message)            
            self.logger.info(f"Working order progress of prices: {execOrder.priceProgressList}")
            self.logger.info(f"Position progress of prices: {bookPosition.priceProgressList}")
            self.logger.debug(f"The {orderType} event happened:")
            self.logger.debug(f" - orderType: {orderType}")
            self.logger.debug(f" - orderTag: {orderTag}")
            self.logger.debug(f" - premium: ${bookPosition[f'{orderType}Premium']}")
            self.logger.debug(f" - {orderType} price: ${round(execOrder.fillPrice, 2)}")

            context.charting.plotTrade(bookPosition, orderType)

            if orderType == "open":
                # Trigger an update of the charts
                context.statsUpdated = True
                # Marks the date/time of the most recenlty opened position
                context.lastOpenedDttm = context.Time
                # Store the credit received (needed to determine the stop loss): value is per share (divided by 100)
                execOrder.premium = bookPosition.openPremium / 100

        # Check if the entire position has been closed
        if orderType == "close" and bookPosition.openOrder.filled and bookPosition.closeOrder.filled:

            # Compute P&L for the position
            positionPnL = bookPosition.openPremium + bookPosition.closePremium

            # Store the PnL for the position
            bookPosition.PnL = positionPnL
            # Now we can remove the position from the self.openPositions dictionary
            context.openPositions.pop(orderTag)

            # Compute the DTE at the time of closing the position
            closeDte = (contract.Expiry.date() - context.Time.date()).days
            # Collect closing trade info
            closeTradeInfo = {"orderTag": orderTag, "closeDte": closeDte}
            # Add this trade info to the FIFO list
            context.recentlyClosedDTE.append(closeTradeInfo)

            # ###########################
            # Collect Performance metrics
            # ###########################
            context.charting.updateStats(bookPosition)

        # Stop the timer
        context.executionTimer.stop()
# ENDsection: handle order events from main.py

#region imports
from AlgorithmImports import *
#endregion


# Custom class: fills orders at the mid-price
class MidPriceFillModel(ImmediateFillModel):
    def __init__(self, context):
        self.context = context

    def MarketFill(self, asset, order):
        # Start the timer
        self.context.executionTimer.start()

        # Call the parent method
        fill = super().MarketFill(asset, order)
        # Compute the new fillPrice (at the mid-price)
        fillPrice = round(0.5 * (asset.AskPrice + asset.BidPrice), 2)
        # Update the FillPrice attribute
        fill.FillPrice = fillPrice
        # Stop the timer
        self.context.executionTimer.stop()
        # Return the fill
        return fill
#region imports
from AlgorithmImports import *
#endregion

from Tools import Timer, Logger, DataHandler, Underlying, Charting
from Initialization import AlwaysBuyingPowerModel, BetaFillModel, TastyWorksFeeModel

"""
    This class is used to setup the base structure of the algorithm in the main.py file.
    It is used to setup the logger, the timer, the brokerage model, the security initializer, the
    option chain filter function and the benchmark.
    It is also used to schedule an event to get the underlying price at market open.
    The class has chainable methods for Setup and AddUnderlying.

    How to use it:
    1. Import the class
    2. Create an instance of the class in the Initialize method of the algorithm
    3. Call the AddUnderlying method to add the underlying and the option chain to the algorithm

    Example:

    from Initialization import SetupBaseStructure

    class Algorithm(QCAlgorithm):
        def Initialize(self):
            # Set the algorithm base variables and structures
            self.structure = SetupBaseStructure(self)
            self.structure.Setup()

            # Add the alpha model and that will add the underlying and the option chain to the
            # algorithm
            self.SetAlpha(AlphaModel(self))

    class AlphaModel:
        def __init__(self, context):
            # Store the context as a class variable
            self.context = context

            # Add the underlying and the option chain to the algorithm
            self.context.structure.AddUnderlying(self, "SPX")
"""


class SetupBaseStructure:

    # Default parameters
    DEFAULT_PARAMETERS = {
        "creditStrategy": True,
        # -----------------------------
        # THESE BELOW ARE GENERAL PARAMETERS
        "backtestMarketCloseCutoffTime": time(15, 45, 0),
        # Controls whether to include Cancelled orders (Limit orders that didn't fill) in the final output
        "includeCancelledOrders": True,
        # Risk Free Rate for the Black-Scholes-Merton model
        "riskFreeRate": 0.001,
        # Upside/Downside stress applied to the underlying to calculate the portfolio margin requirement of the position
        "portfolioMarginStress": 0.12,
        # Controls the memory (in minutes) of EMA process. The exponential decay
        # is computed such that the contribution of each value decays by 95%
        # after <emaMemory> minutes (i.e. decay^emaMemory = 0.05)
        "emaMemory": 200,
    }

    # Initialize the algorithm
    # The context is the class that contains all the variables that are shared across the different classes
    def __init__(self, context):
        # Store the context as a class variable
        self.context = context

    def Setup(self):
        self.context.positions = {}

        # Set the logger
        self.context.logger = Logger(self.context, className=type(self.context).__name__, logLevel=self.context.logLevel)

        # Set the timer to monitor the execution performance
        self.context.executionTimer = Timer(self.context)
        self.context.logger.debug(f'{self.__class__.__name__} -> Setup')
        # Set brokerage model and margin account
        self.context.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Margin)
        # override security position group model
        self.context.Portfolio.SetPositions(SecurityPositionGroupModel.Null)
        # Set requested data resolution
        self.context.universe_settings.resolution = self.context.timeResolution

        # Keep track of the option contract subscriptions
        self.context.optionContractsSubscriptions = []
        # Set Security Initializer
        self.context.SetSecurityInitializer(self.CompleteSecurityInitializer)
        # Initialize the dictionary to keep track of all positions
        self.context.allPositions = {}

        # Dictionary to keep track of all open positions
        self.context.openPositions = {}

        # Create dictionary to keep track of all the working orders. It stores orderTags
        self.context.workingOrders = {}

        # Create FIFO list to keep track of all the recently closed positions (needed for the Dynamic DTE selection)
        self.context.recentlyClosedDTE = []

        # Keep track of when was the last position opened
        self.context.lastOpenedDttm = None

        # Keep track of all strategies instances. We mainly need this to filter through them in case
        # we want to call some general method.
        self.context.strategies = []

        # Array to keep track of consolidators
        self.context.consolidators = {}

        # Dictionary to keep track of all leg details across time
        self.positionTracking = {}

        # Assign the DEFAULT_PARAMETERS
        self.AddConfiguration(**SetupBaseStructure.DEFAULT_PARAMETERS)
        self.SetBacktestCutOffTime()

        # Set charting
        self.context.charting = Charting(
            self.context, 
            openPositions=False, 
            Stats=False, 
            PnL=False, 
            WinLossStats=False, 
            Performance=True, 
            LossDetails=False, 
            totalSecurities=False, 
            Trades=True
        )

        return self

    # Called every time a security (Option or Equity/Index) is initialized
    def CompleteSecurityInitializer(self, security: Security) -> None:
        '''Initialize the security with raw prices'''
        self.context.logger.debug(f"{self.__class__.__name__} -> CompleteSecurityInitializer -> Security: {security}")
        
        # Disable buying power on the security: https://www.quantconnect.com/docs/v2/writing-algorithms/live-trading/trading-and-orders#10-Disable-Buying-Power
        security.set_buying_power_model(BuyingPowerModel.NULL)

        if self.context.LiveMode:
            return

        self.context.executionTimer.start()

        security.SetDataNormalizationMode(DataNormalizationMode.Raw)
        security.SetMarketPrice(self.context.GetLastKnownPrice(security))
        # security.SetBuyingPowerModel(AlwaysBuyingPowerModel(self.context))
        # override margin requirements
        # security.SetBuyingPowerModel(ConstantBuyingPowerModel(1))

        if security.Type == SecurityType.Equity:
            # This is for stocks
            security.VolatilityModel = StandardDeviationOfReturnsVolatilityModel(30)
            history = self.context.History(security.Symbol, 31, Resolution.Daily)

            if history.empty or 'close' not in history.columns:
                self.context.executionTimer.stop()
                return

            for time, row in history.loc[security.Symbol].iterrows():
                trade_bar = TradeBar(time, security.Symbol, row.open, row.high, row.low, row.close, row.volume)
                security.VolatilityModel.Update(security, trade_bar)

        elif security.Type in [SecurityType.Option, SecurityType.IndexOption]:
            # This is for options.
            security.SetFillModel(BetaFillModel(self.context))
            # security.SetFillModel(MidPriceFillModel(self))
            security.SetFeeModel(TastyWorksFeeModel())
            security.PriceModel = OptionPriceModels.CrankNicolsonFD()
            # security.set_option_assignment_model(NullOptionAssignmentModel())
        if security.Type == SecurityType.IndexOption:
            # disable option assignment. This is important for SPX but we disable for all for now.
            security.SetOptionAssignmentModel(NullOptionAssignmentModel())
        self.context.executionTimer.stop()

    def ClearSecurity(self, security: Security) -> None:
        """
        Remove any additional data or settings associated with the security.
        """
        # Remove the security from the optionContractsSubscriptions dictionary
        if security.Symbol in self.context.optionContractsSubscriptions:
            self.context.optionContractsSubscriptions.remove(security.Symbol)

        # Remove the security from the algorithm
        self.context.RemoveSecurity(security.Symbol)

    def SetBacktestCutOffTime(self) -> None:
        # Determine what is the last trading day of the backtest
        self.context.endOfBacktestCutoffDttm = None
        if hasattr(self.context, "EndDate") and self.context.EndDate is not None:
            self.context.endOfBacktestCutoffDttm = datetime.combine(self.context.lastTradingDay(self.context.EndDate), self.context.backtestMarketCloseCutoffTime)

    def AddConfiguration(self, parent=None, **kwargs) -> None:
        """
        Dynamically add attributes to the self.context object.

        :param parent: Parent object to which the attributes will be added.
        :param kwargs: Keyword arguments containing attribute names and their values.
        """
        parent = parent or self.context
        for attr_name, attr_value in kwargs.items():
            setattr(parent, attr_name, attr_value)

    # Add the underlying and the option chain to the algorithm. We define the number of strikes left and right,
    # the dte and the dte window. These parameters are used in the option chain filter function.
    # @param ticker [string]
    def AddUnderlying(self, strategy, ticker):
        self.context.strategies.append(strategy)
        # Store the algorithm base variables
        strategy.ticker = ticker
        self.context.logger.debug(f"{self.__class__.__name__} -> AddUnderlying -> Ticker: {ticker}")
        # Add the underlying and the option chain to the algorithm
        strategy.dataHandler = DataHandler(self.context, ticker, strategy)
        underlying = strategy.dataHandler.AddUnderlying(self.context.timeResolution)
        option = strategy.dataHandler.AddOptionsChain(underlying, self.context.timeResolution)
        # Set data normalization mode to Raw
        underlying.SetDataNormalizationMode(DataNormalizationMode.Raw)
        self.context.logger.debug(f"{self.__class__.__name__} -> AddUnderlying -> Underlying: {underlying}")
        # Keep track of the option contract subscriptions
        self.context.optionContractsSubscriptions = []

        # Set the option chain filter function
        option.SetFilter(strategy.dataHandler.SetOptionFilter)
        self.context.logger.debug(f"{self.__class__.__name__} -> AddUnderlying -> Option: {option}")
        # Store the symbol for the option and the underlying
        strategy.underlyingSymbol = underlying.Symbol
        strategy.optionSymbol = option.Symbol

        # Set the benchmark.
        self.context.SetBenchmark(underlying.Symbol)
        self.context.logger.debug(f"{self.__class__.__name__} -> AddUnderlying -> Benchmark: {self.context.Benchmark}")
        # Creating a 5-minute consolidator.
        # self.AddConsolidators(strategy.underlyingSymbol, 5)

        # !IMPORTANT
        # !     this schedule needs to happen only once on initialization. That means the method AddUnderlying
        # !     needs to be called only once either in the main.py file or in the AlphaModel class.
        self.context.Schedule.On(
            self.context.DateRules.EveryDay(strategy.underlyingSymbol),
            self.context.TimeRules.AfterMarketOpen(strategy.underlyingSymbol, minutesAfterOpen=1),
            self.MarketOpenStructure
        )

        return self

    def AddConsolidators(self, symbol, minutes=5):
        consolidator = TradeBarConsolidator(timedelta(minutes=minutes))
        # Subscribe to the DataConsolidated event
        consolidator.DataConsolidated += self.onDataConsolidated
        self.context.SubscriptionManager.AddConsolidator(symbol, consolidator)
        self.context.consolidators[symbol] = consolidator

    def onDataConsolidated(self, sender, bar):
        for strategy in self.context.strategies:
            # We don't have the underlying added yet, so we can't get the price.
            if strategy.underlyingSymbol == None:
                return

            strategy.dataConsolidated(sender, bar)

        self.context.charting.updateUnderlying(bar)

    # NOTE: this is not needed anymore as we have another method in alpha that handles it.
    def MarketOpenStructure(self):
        """
        The MarketOpenStructure method is part of the SetupBaseStructure class, which is used to
        set up the base structure of the algorithm in the main.py file. This specific method is
        designed to be called at market open every day to update the price of the underlying
        security. It first checks if the underlying symbol has been added to the context, and if
        not, it returns without performing any action. If the underlying symbol is available, it
        creates an instance of the Underlying class using the context and the symbol. Finally,
        it updates the underlying price at the market open by calling the Price() method on the
        Underlying instance.

        Example:
        Schedule the MarketOpenStructure method to be called at market open

        self.Schedule.On(self.DateRules.EveryDay(), self.TimeRules.AfterMarketOpen(self.strategy.underlyingSymbol, 0), base_structure.MarketOpenStructure)

        Other methods, like OnData, can now access the updated underlying price using self.context.underlyingPriceAtOpen
        """
        for strategy in self.context.strategies:
            # We don't have the underlying added yet, so we can't get the price.
            if strategy.underlyingSymbol == None:
                return

            underlying = Underlying(self.context, strategy.underlyingSymbol)
            strategy.underlyingPriceAtOpen = underlying.Price()

    # This just clears the workingOrders that are supposed to be expired or unfilled. It can happen when an order is not filled
    # for it to stay in check until next day. This will clear that out. Similar method to the monitor one.
    def checkOpenPositions(self):
        self.context.executionTimer.start()
        # Iterate over all option contracts and remove the expired ones from the
        for symbol, security in self.context.Securities.items():
            # Check if the security is an option
            if security.Type == SecurityType.Option and security.HasData:
                # Check if the option has expired
                if security.Expiry.date() < self.context.Time.date():
                    self.context.logger.debug(f"  >>>  EXPIRED SECURITY-----> Removing expired {security.Expiry.date()} option contract {security.Symbol} from the algorithm.")
                    # Remove the expired option contract
                    self.ClearSecurity(security)

        # Remove the expired positions from the openPositions dictionary. These are positions that expired
        # worthless or were closed before expiration.
        for orderTag, orderId in list(self.context.openPositions.items()):
            position = self.context.allPositions[orderId]
            # Check if we need to cancel the order
            if any(self.context.Time > leg.expiry for leg in position.legs):
                # Remove this position from the list of open positions
                self.context.charting.updateStats(position)
                self.context.logger.debug(f"  >>>  EXPIRED POSITION-----> Removing expired position {orderTag} from the algorithm.")
                self.context.openPositions.pop(orderTag)

        # Remove the expired positions from the workingOrders dictionary. These are positions that expired
        # without being filled completely.
        for order in list(self.context.workingOrders.values()):
            position = self.context.allPositions[order.orderId]
            orderTag = position.orderTag
            orderId = position.orderId
            orderType = order.orderType
            execOrder = position[f"{orderType}Order"]

            # Check if we need to cancel the order
            if self.context.Time > execOrder.limitOrderExpiryDttm or any(self.context.Time > leg.expiry for leg in position.legs):
                self.context.logger.debug(f"  >>>  EXPIRED ORDER-----> Removing expired order {orderTag} from the algorithm.")
                # Remove this position from the list of open positions
                if orderTag in self.context.openPositions:
                    self.context.openPositions.pop(orderTag)
                # Remove the cancelled position from the final output unless we are required to include it
                if not self.context.includeCancelledOrders:
                    self.context.allPositions.pop(orderId)
                # Remove the order from the self.context.workingOrders dictionary
                if orderTag in self.context.workingOrders:
                    self.context.workingOrders.pop(orderTag)
                # Mark the order as being cancelled
                position.cancelOrder(self.context, orderType=orderType, message=f"order execution expiration or legs expired")
        self.context.executionTimer.stop()

#region imports
from AlgorithmImports import *
#endregion


class TastyWorksFeeModel:
    def GetOrderFee(self, parameters):
        optionFee = min(10, parameters.Order.AbsoluteQuantity * 0.5)
        transactionFee = parameters.Order.AbsoluteQuantity * 0.14
        return OrderFee(CashAmount(optionFee + transactionFee, 'USD'))

#region imports
from AlgorithmImports import *
from .AlwaysBuyingPowerModel import AlwaysBuyingPowerModel
from .BetaFillModel import BetaFillModel
from .MidPriceFillModel import MidPriceFillModel
from .TastyWorksFeeModel import TastyWorksFeeModel
from .SetupBaseStructure import SetupBaseStructure
from .HandleOrderEvents import HandleOrderEvents
#endregion
#region imports
from AlgorithmImports import *
#endregion

from Initialization import SetupBaseStructure
from Strategy import WorkingOrder
from Tools import Underlying


class Base(RiskManagementModel):
    DEFAULT_PARAMETERS = {
        # The frequency (in minutes) with which each position is managed
        "managePositionFrequency": 1,
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        "profitTarget": 0.8,
        # Stop Loss Multiplier, expressed as a function of the profit target (rather than the credit received)
        # The position is closed (Market Order) if:
        #    Position P&L < -abs(openPremium) * stopLossMultiplier
        # where:
        #  - openPremium is the premium received (positive) in case of credit strategies
        #  - openPremium is the premium paid (negative) in case of debit strategies
        #
        # Credit Strategies (i.e. $2 credit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $1 profit)
        #  - stopLossMultiplier = 2 * profitTarget (i.e. -abs(openPremium) * stopLossMultiplier = -abs(2) * 2 * 0.5 = -2 --> stop if P&L < -2$)
        # Debit Strategies (i.e. $4 debit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $2 profit)
        #  - stopLossMultiplier < 1 (You can't lose more than the debit paid. i.e. stopLossMultiplier = 0.6 --> stop if P&L < -2.4$)
        # self.stopLossMultiplier = 3 * self.profitTarget
        # self.stopLossMultiplier = 0.6
        "stopLossMultiplier": 1.9,
        # Ensures that the Stop Loss does not exceed the theoretical loss. (Set to False for Credit Calendars)
        "capStopLoss": True,
    }

    def __init__(self, context):
        self.context = context
        self.context.structure.AddConfiguration(parent=self, **self.getMergedParameters())
        self.context.logger.debug(f"{self.__class__.__name__} -> __init__")

    @classmethod
    def getMergedParameters(cls):
        # Merge the DEFAULT_PARAMETERS from both classes
        return {**cls.DEFAULT_PARAMETERS, **getattr(cls, "PARAMETERS", {})}

    @classmethod
    def parameter(cls, key, default=None):
        return cls.getMergedParameters().get(key, default)

    # @param algorithm [QCAlgorithm] The algorithm argument that the methods receive is an instance of the base QCAlgorithm class, not your subclass of it.
    # @param targets [List[PortfolioTarget]] The list of targets to be ordered
    def ManageRisk(self, algorithm: QCAlgorithm, targets: List[PortfolioTarget]) -> List[PortfolioTarget]:
        # Start the timer
        self.context.executionTimer.start('Monitor.Base -> ManageRisk')
        # We are basically ignoring the current portfolio targets to be assessed for risk
        # and building our own based on the current open positions
        targets = []
        self.context.logger.debug(f"{self.__class__.__name__} -> ManageRisk -> start")
        managePositionFrequency = max(self.managePositionFrequency, 1)

        # Continue the processing only if we are at the specified schedule
        if self.context.Time.minute % managePositionFrequency != 0:
            return []

        # Method to allow child classes access to the manageRisk method before any changes are made
        self.preManageRisk()
        self.context.logger.debug(f"{self.__class__.__name__} -> ManageRisk -> preManageRisk")
        # Loop through all open positions
        for orderTag, orderId in list(self.context.openPositions.items()):
            # Skip this contract if in the meantime it has been removed by the onOrderEvent
            if orderTag not in self.context.openPositions:
                continue
            self.context.logger.debug(f"{self.__class__.__name__} -> ManageRisk -> looping through open positions")
            # Get the book position
            bookPosition = self.context.allPositions[orderId]
            # Get the order id
            orderId = bookPosition.orderId
            # Get the order tag
            orderTag = bookPosition.orderTag

            self.context.logger.debug(f"{self.__class__.__name__} -> ManageRisk -> looping through open positions -> orderTag: {orderTag}, orderId: {orderId}")

            # Check if this is a fully filled position
            if bookPosition.openOrder.filled is False:
                continue

            # Possible Scenarios:
            #   - Credit Strategy:
            #        -> openPremium > 0
            #        -> profitTarget <= 1
            #        -> stopLossMultiplier >= 1
            #        -> maxLoss = Depending on the strategy
            #   - Debit Strategy:
            #        -> openPremium < 0
            #        -> profitTarget >= 0
            #        -> stopLossMultiplier <= 1
            #        -> maxLoss = openPremium

            # Get the current value of the position
            bookPosition.getPositionValue(self.context)
            # Extract the positionPnL (per share)
            positionPnL = bookPosition.positionPnL

            # Exit if the positionPnL is not available (bid-ask spread is too wide)
            if positionPnL is None:
                return []

            bookPosition.updatePnLRange(self.context.Time.date(), positionPnL)

            self.context.logger.debug(f"{self.__class__.__name__} -> ManageRisk -> looping through open positions -> orderTag: {orderTag}, orderId: {orderId} -> bookPosition: {bookPosition}")

            # Special method to monitor the position and handle custom actions on it.
            self.monitorPosition(bookPosition)

            # Initialize the closeReason
            closeReason = []

            # Check if we've hit the stop loss threshold
            stopLossFlg = self.checkStopLoss(bookPosition)
            if stopLossFlg:
                closeReason.append("Stop Loss trigger")

            profitTargetFlg = self.checkProfitTarget(bookPosition)
            if profitTargetFlg:
                closeReason.append("Profit target")

            # Check if we've hit the Dit threshold
            hardDitStopFlg, softDitStopFlg = self.checkDitThreshold(bookPosition)
            if hardDitStopFlg:
                closeReason.append("Hard Dit cutoff")
            elif softDitStopFlg:
                closeReason.append("Soft Dit cutoff")

            # Check if we've hit the Dte threshold
            hardDteStopFlg, softDteStopFlg = self.checkDteThreshold(bookPosition)
            if hardDteStopFlg:
                closeReason.append("Hard Dte cutoff")
            elif softDteStopFlg:
                closeReason.append("Soft Dte cutoff")

            # Check if this is the last trading day before expiration and we have reached the cutoff time
            expiryCutoffFlg = self.checkMarketCloseCutoffDttm(bookPosition)
            if expiryCutoffFlg:
                closeReason.append("Expiration date cutoff")

            # Check if this is the last trading day before expiration and we have reached the cutoff time
            endOfBacktestCutoffFlg = self.checkEndOfBacktest()
            if endOfBacktestCutoffFlg:
                closeReason.append("End of backtest cutoff")
                # Set the stopLossFlg = True to force a Market Order
                stopLossFlg = True

            # Check any custom condition from the strategy to determine closure.
            shouldCloseFlg, customReasons = self.shouldClose(bookPosition)
            if shouldCloseFlg:
                closeReason.append(customReasons or "It should close from child")

            # A custom method to handle
            self.context.logger.debug(f"{self.__class__.__name__} -> ManageRisk -> looping through open positions -> orderTag: {orderTag}, orderId: {orderId} -> shouldCloseFlg: {shouldCloseFlg}, customReasons: {customReasons}")

            # Update the stats of each contract
            # TODO: add back this section
            # if self.strategyParam("includeLegDetails") and self.context.Time.minute % self.strategyParam("legDatailsUpdateFrequency") == 0:
            #     for contract in position["contracts"]:
            #         self.updateContractStats(bookPosition, position, contract)
            #     if self.strategyParam("trackLegDetails"):
            #         underlyingPrice = self.context.GetLastKnownPrice(self.context.Securities[self.context.underlyingSymbol]).Price
            #         self.context.positionTracking[orderId][self.context.Time][f"{self.name}.underlyingPrice"] = underlyingPrice
            #         self.context.positionTracking[orderId][self.context.Time][f"{self.name}.PnL"] = positionPnL

            # Check if we need to close the position
            if (
                profitTargetFlg  # We hit the profit target
                or stopLossFlg  # We hit the stop loss (making sure we don't exceed the max loss in case of spreads)
                or hardDteStopFlg  # The position must be closed when reaching the DTE threshold (hard stop)
                or softDteStopFlg  # Soft DTE stop: close as soon as it is profitable
                or hardDitStopFlg  # The position must be closed when reaching the DIT threshold (hard stop)
                or softDitStopFlg  # Soft DIT stop: close as soon as it is profitable
                or expiryCutoffFlg  # This is the last trading day before expiration, we have reached the cutoff time
                or endOfBacktestCutoffFlg  # This is the last trading day before the end of the backtest -> Liquidate all positions
                or shouldCloseFlg # This will be the flag that is defined by the child classes of monitor
            ):
                # Close the position
                targets = self.closePosition(bookPosition, closeReason, stopLossFlg=stopLossFlg)

        # Stop the timer
        self.context.executionTimer.stop('Monitor.Base -> ManageRisk')

        return targets

    """
    Method to allow child classes access to the manageRisk method before any changes are made
    """
    def preManageRisk(self):
        pass

    """
    Special method to monitor the position and handle custom actions on it.
    These actions can be:
    - add a working order to open a hedge position to defend the current one
    - add a working order to increase the size of the position to improve avg price
    """
    def monitorPosition(self, position):
        pass

    """
    Another special method that should be ovewritten by child classes.
    This method can look for indicators or other decisions to close the position.
    """
    def shouldClose(self, position):
        pass

    def checkMarketCloseCutoffDttm(self, position):
        if position.strategyParam('marketCloseCutoffTime') != None:
            return self.context.Time >= position.expiryMarketCloseCutoffDttm(self.context)
        else:
            return False

    def checkStopLoss(self, position):
        # Get the Stop Loss multiplier
        stopLossMultiplier = self.stopLossMultiplier
        capStopLoss = self.capStopLoss

        # Get the amount of credit received to open the position
        openPremium = position.openOrder.premium
        # Get the quantity used to open the position
        positionQuantity = position.orderQuantity
        # Maximum Loss (pre-computed at the time of creating the order)
        maxLoss = position.openOrder.maxLoss * positionQuantity
        if capStopLoss:
            # Add the premium to compute the net loss
            netMaxLoss = maxLoss + openPremium
        else:
            netMaxLoss = float("-Inf")

        stopLoss = None
        # Check if we are using a stop loss
        if stopLossMultiplier is not None:
            # Set the stop loss amount
            stopLoss = -abs(openPremium) * stopLossMultiplier

        # Extract the positionPnL (per share)
        positionPnL = position.positionPnL

        # Tolerance level, e.g., 0.05 for 5%
        tolerance = 0.05

        # Check if we've hit the stop loss threshold or are within the tolerance range
        stopLossFlg = False
        if stopLoss is not None and (netMaxLoss <= positionPnL <= stopLoss or netMaxLoss <= positionPnL <= stopLoss * (1 + tolerance)):
            stopLossFlg = True

        # Keep track of the midPrices of this order for faster debugging
        position.priceProgressList.append(round(position.orderMidPrice, 2))

        return stopLossFlg

    def checkProfitTarget(self, position):
        # Get the amount of credit received to open the position
        openPremium = position.openOrder.premium
        # Extract the positionPnL (per share)
        positionPnL = position.positionPnL

        # Get the target profit amount (if it has been set at the time of creating the order)
        targetProfit = position.targetProfit
        # Set the target profit amount if the above step returned no value
        if targetProfit is None and self.profitTarget is not None:
            targetProfit = abs(openPremium) * self.profitTarget

        # Tolerance level, e.g., 0.05 for 5%
        tolerance = 0.05

        # Check if we hit the profit target or are within the tolerance range
        profitTargetFlg = False
        if targetProfit is not None and (positionPnL >= targetProfit or positionPnL >= targetProfit * (1 - tolerance)):
            profitTargetFlg = True

        return profitTargetFlg


    def checkDitThreshold(self, position):
        # Get the book position
        bookPosition = self.context.allPositions[position.orderId]
        # How many days has this position been in trade for
        currentDit = (self.context.Time.date() - bookPosition.openFilledDttm.date()).days

        hardDitStopFlg = False
        softDitStopFlg = False

        # Extract the positionPnL (per share)
        positionPnL = position.positionPnL

        # Check for DTE stop
        if (
            position.strategyParam("ditThreshold") is not None  # The ditThreshold has been specified
            and position.strategyParam("dte") > position.strategyParam("ditThreshold")  # We are using the ditThreshold only if the open DTE was larger than the threshold
            and currentDit >= position.strategyParam("ditThreshold")  # We have reached the DTE threshold
        ):
            # Check if this is a hard DTE cutoff
            if (
                position.strategyParam("forceDitThreshold") is True
                or (position.strategyParam("hardDitThreshold") is not None and currentDit >= position.strategyParam("hardDitThreshold"))
            ):
                hardDitStopFlg = True
                # closeReason = closeReason or "Hard DIT cutoff"
            # Check if this is a soft DTE cutoff
            elif positionPnL >= 0:
                softDitStopFlg = True
                # closeReason = closeReason or "Soft DIT cutoff"

        return hardDitStopFlg, softDitStopFlg

    def checkDteThreshold(self, position):
        hardDteStopFlg = False
        softDteStopFlg = False
        # Extract the positionPnL (per share)
        positionPnL = position.positionPnL
        # How many days to expiration are left for this position
        currentDte = (position.expiry.date() - self.context.Time.date()).days
        # Check for DTE stop
        if (
            position.strategyParam("dteThreshold") is not None  # The dteThreshold has been specified
            and position.strategyParam("dte") > position.strategyParam("dteThreshold")  # We are using the dteThreshold only if the open DTE was larger than the threshold
            and currentDte <= position.strategyParam("dteThreshold")  # We have reached the DTE threshold
        ):
            # Check if this is a hard DTE cutoff
            if position.strategyParam("forceDteThreshold") is True:
                hardDteStopFlg = True
                # closeReason = closeReason or "Hard DTE cutoff"
            # Check if this is a soft DTE cutoff
            elif positionPnL >= 0:
                softDteStopFlg = True
                # closeReason = closeReason or "Soft DTE cutoff"

        return hardDteStopFlg, softDteStopFlg

    def checkEndOfBacktest(self):
        if self.context.endOfBacktestCutoffDttm is not None and self.context.Time >= self.context.endOfBacktestCutoffDttm:
            return True
        return False

    def closePosition(self, position, closeReason, stopLossFlg=False):
        # Start the timer
        self.context.executionTimer.start()

        targets = []

        # Get the context
        context = self.context
        # Get the strategy parameters
        # parameters = self.parameters

        # Get Order Id and expiration
        orderId = position.orderId
        # expiryStr = position.expiryStr
        orderTag = position.orderTag
        orderMidPrice = position.orderMidPrice
        limitOrderPrice = position.limitOrderPrice
        bidAskSpread = position.bidAskSpread

        # Get the details currently open position
        openPosition = context.openPositions[orderTag]
        # Get the book position
        bookPosition = context.allPositions[orderId]
        # Get the last trading day before expiration
        expiryLastTradingDay = bookPosition.expiryLastTradingDay(context)
        # Get the date/time threshold by which the position must be closed (on the last trading day before expiration)
        expiryMarketCloseCutoffDttm = None
        if bookPosition.strategyParam("marketCloseCutoffTime") != None:
            expiryMarketCloseCutoffDttm = bookPosition.expiryMarketCloseCutoffDttm(context)

        # Get the contracts and their side
        contracts = [l.contract for l in bookPosition.legs]
        contractSide = bookPosition.contractSide
        # Set the expiration threshold at 15:40 of the expiration date (but no later than the market close cut-off time).
        expirationThreshold = None
        if expiryMarketCloseCutoffDttm != None:
            expirationThreshold = min(expiryLastTradingDay + timedelta(hours=15, minutes=40), expiryMarketCloseCutoffDttm + bookPosition.strategyParam("limitOrderExpiration"))
            # Set the expiration date for the Limit order. Make sure it does not exceed the expiration threshold
            limitOrderExpiryDttm = min(context.Time + bookPosition.strategyParam("limitOrderExpiration"), expirationThreshold)
        else:
            limitOrderExpiryDttm = min(context.Time + bookPosition.strategyParam("limitOrderExpiration"), expiryLastTradingDay + timedelta(hours=15, minutes=40))

        # Determine if we are going to use a Limit Order
        useLimitOrders = (
            # Check if we are supposed to use Limit orders as a default
            bookPosition.strategyParam("useLimitOrders")
            # Make sure there is enough time left to expiration.
            # Once we cross the expiration threshold (10 minutes from market close on the expiration day) we are going to submit a Market order
            and (expirationThreshold is None or context.Time <= expirationThreshold)
            # It's not a stop loss (stop losses are executed through a Market order)
            and not stopLossFlg
        )
        # Determine if we are going to use a Market Order
        useMarketOrders = not useLimitOrders

        # Get the price of the underlying at the time of closing the position
        priceAtClose = None
        if context.Securities.ContainsKey(bookPosition.underlyingSymbol()):
            if context.Securities[bookPosition.underlyingSymbol()] is not None:
                priceAtClose = context.Securities[bookPosition.underlyingSymbol()].Close
            else:
                self.context.logger.warning("priceAtClose is None")

        # Set the midPrice for the order to close
        bookPosition.closeOrder.orderMidPrice = orderMidPrice
        # Set the Limit order expiration.
        bookPosition.closeOrder.limitOrderExpiryDttm = limitOrderExpiryDttm

        # Set the timestamp when the closing order is created
        bookPosition.closeDttm = context.Time
        # Set the date when the closing order is created
        bookPosition.closeDt = context.Time.strftime("%Y-%m-%d")
        # Set the price of the underlying at the time of submitting the order to close
        bookPosition.underlyingPriceAtOrderClose = priceAtClose
        # Set the price of the underlying at the time of submitting the order to close:
        # - This is the same as underlyingPriceAtOrderClose in case of Market Orders
        # - In case of Limit orders, this is the actual price of the underlying at the time when the Limit Order was triggered (price is updated later by the manageLimitOrders method)
        bookPosition.underlyingPriceAtClose = priceAtClose
        # Set the mid-price of the position at the time of closing
        bookPosition.closeOrderMidPrice = orderMidPrice
        bookPosition.closeOrderMidPriceMin = orderMidPrice
        bookPosition.closeOrderMidPriceMax = orderMidPrice
        # Set the Limit Order price of the position at the time of closing
        bookPosition.closeOrderLimitPrice = limitOrderPrice
        bookPosition.closeOrder.limitOrderPrice = limitOrderPrice
        # Set the close DTE
        bookPosition.closeDTE = (bookPosition.expiry.date() - context.Time.date()).days
        # Set the Days in Trade
        bookPosition.DIT = (context.Time.date() - bookPosition.openFilledDttm.date()).days
        # Set the close reason
        bookPosition.closeReason = closeReason

        if useMarketOrders:
            # Log the parameters used to validate the order
            self.context.logger.debug("Executing Market Order to close the position:")
            self.context.logger.debug(f" - orderTag: {orderTag}")
            self.context.logger.debug(f" - strikes: {[c.Strike for c in contracts]}")
            self.context.logger.debug(f" - orderQuantity: {bookPosition.orderQuantity}")
            self.context.logger.debug(f" - midPrice: {orderMidPrice}")
            self.context.logger.debug(f" - bidAskSpread: {bidAskSpread}")
            self.context.logger.debug(f" - closeReason: {closeReason}")
            # Store the Bid-Ask spread at the time of executing the order
            bookPosition["closeOrderBidAskSpread"] = bidAskSpread

        # legs = []
        # isComboOrder = len(contracts) > 1

        if useMarketOrders:
            position.limitOrder = False
        elif useLimitOrders:
            position.limitOrder = True

        for leg in position.legs:
            # Extract order parameters
            symbol = leg.symbol
            orderSide = leg.orderSide
            # orderQuantity = leg.orderQuantity

            # TODO: I'm not sure about this order side check here
            if orderSide != 0:
                targets.append(PortfolioTarget(symbol, orderSide))

        # Submit the close orders
        context.workingOrders[orderTag] = WorkingOrder(
            targets=targets,
            orderId=orderId,
            useLimitOrder=useLimitOrders,
            limitOrderPrice=limitOrderPrice,
            orderType="close",
            fills=0
        )

        # Stop the timer
        context.executionTimer.stop()
        return targets

    # Optional: Be notified when securities change
    def OnSecuritiesChanged(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
        pass
            

#region imports
from AlgorithmImports import *
#endregion

from .Base import Base
from CustomIndicators import ATRLevels
from Tools import Underlying
from Strategy import WorkingOrder

class CCMonitor(Base):
    DEFAULT_PARAMETERS = {
        # The frequency (in minutes) with which each position is managed
        "managePositionFrequency": 5,
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        "profitTarget": 0.5,
        # Stop Loss Multiplier, expressed as a function of the profit target (rather than the credit received)
        # The position is closed (Market Order) if:
        #    Position P&L < -abs(openPremium) * stopLossMultiplier
        # where:
        #  - openPremium is the premium received (positive) in case of credit strategies
        #  - openPremium is the premium paid (negative) in case of debit strategies
        #
        # Credit Strategies (i.e. $2 credit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $1 profit)
        #  - stopLossMultiplier = 2 * profitTarget (i.e. -abs(openPremium) * stopLossMultiplier = -abs(2) * 2 * 0.5 = -2 --> stop if P&L < -2$)
        # Debit Strategies (i.e. $4 debit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $2 profit)
        #  - stopLossMultiplier < 1 (You can't lose more than the debit paid. i.e. stopLossMultiplier = 0.6 --> stop if P&L < -2.4$)
        # self.stopLossMultiplier = 3 * self.profitTarget
        # self.stopLossMultiplier = 0.6
        "stopLossMultiplier": 1,
        # Ensures that the Stop Loss does not exceed the theoretical loss. (Set to False for Credit Calendars)
        "capStopLoss": True,
    }

    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
        self.fiveMinuteITM = {}
        self.HODLOD = {}
        self.triggerHODLOD = {}
        # The dictionary of consolidators
        self.consolidators = dict()
        # self.ATRLevels = ATRLevels("ATRLevels", length = 14)
        # EMAs for the 8, 21 and 34 periods
        self.EMAs = {8: {}, 21: {}, 34: {}}
        # self.stdDevs = {}
        # Add a dictionary to keep track of whether the position reached 50% profit
        self.reachedHalfProfit = {}

    def monitorPosition(self, position):
        pass

    def shouldClose(self, position):
        return False, None

    
#region imports
from AlgorithmImports import *
#endregion

from .Base import Base
from CustomIndicators import ATRLevels
from Tools import Underlying
from Strategy import WorkingOrder

class FPLMonitorModel(Base):
    DEFAULT_PARAMETERS = {
        # The frequency (in minutes) with which each position is managed
        "managePositionFrequency": 1,
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        "profitTarget": 0.5,
        # Stop Loss Multiplier, expressed as a function of the profit target (rather than the credit received)
        # The position is closed (Market Order) if:
        #    Position P&L < -abs(openPremium) * stopLossMultiplier
        # where:
        #  - openPremium is the premium received (positive) in case of credit strategies
        #  - openPremium is the premium paid (negative) in case of debit strategies
        #
        # Credit Strategies (i.e. $2 credit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $1 profit)
        #  - stopLossMultiplier = 2 * profitTarget (i.e. -abs(openPremium) * stopLossMultiplier = -abs(2) * 2 * 0.5 = -2 --> stop if P&L < -2$)
        # Debit Strategies (i.e. $4 debit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $2 profit)
        #  - stopLossMultiplier < 1 (You can't lose more than the debit paid. i.e. stopLossMultiplier = 0.6 --> stop if P&L < -2.4$)
        # self.stopLossMultiplier = 3 * self.profitTarget
        # self.stopLossMultiplier = 0.6
        "stopLossMultiplier": 2,
        # Ensures that the Stop Loss does not exceed the theoretical loss. (Set to False for Credit Calendars)
        "capStopLoss": True,
    }

    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
        self.fiveMinuteITM = {}
        self.HODLOD = {}
        self.triggerHODLOD = {}
        # The dictionary of consolidators
        self.consolidators = dict()
        self.ATRLevels = ATRLevels("ATRLevels", length = 14)
        # EMAs for the 8, 21 and 34 periods
        self.EMAs = {8: {}, 21: {}, 34: {}}
        # self.stdDevs = {}
        # Add a dictionary to keep track of whether the position reached 50% profit
        self.reachedHalfProfit = {}

    def monitorPosition(self, position):
        """
        TODO:
        # These can be complementar
        - check if 2x(1.0) premium was reached and increase position quantity
        - check if 2.5x(1.5) premium was reached and increase position
        """
        symbol = position.underlyingSymbol()
        underlying = Underlying(self.context, position.underlyingSymbol())
        
        # Check if any price in the priceProgressList reached 50% profit
        if any(abs(price*100) / abs(position.openOrder.fillPrice*100) <= 0.5 for price in position.priceProgressList):
            self.reachedHalfProfit[position.orderTag] = True

        # Check if the price of the position reaches 1.0 premium (adding a buffer so we can try and get a fill at 1.0)
        if any(price / abs(position.openOrder.fillPrice) >= 0.9 for price in position.priceProgressList):
            # Increase the quantity by 50%
            new_quantity = position.Quantity * 1.5
            orderTag = position.orderTag
            orderId = position.orderId
            self.context.workingOrders[orderTag] = WorkingOrder(
                orderId=orderId,
                useLimitOrder=True,
                orderType="update",
                limitOrderPrice=1.0,
                fills=0,
                quantity=new_quantity
            )

        bar = underlying.Security().GetLastData()
        stats = position.strategy.stats

        if bar is not None:
            high = bar.High
            low = bar.Low

            for period, emas in self.EMAs.items():
                if symbol in emas:
                    ema = emas[symbol]
                    if ema.IsReady:
                        if low <= ema.Current.Value <= high:
                            # The price has touched the EMA
                            stats.touchedEMAs[symbol] = True

    def shouldClose(self, position):
        """
        TODO:
        - check if ATR indicator has been breached and exit
        - check if half premium was reached and close 50%-80% of position (not really possible now as we have a True/False return)
        """
        score = 0
        reason = ""
        stats = position.strategy.stats

        # Assign a score of 3 if 5m ITM threshold is met
        if position.orderTag in self.fiveMinuteITM and self.fiveMinuteITM[position.orderTag]:
            score += 3
            reason = "5m ITM"

        # Assign a score of 1 if HOD/LOD breach occurs
        if position.orderTag in self.HODLOD and self.HODLOD[position.orderTag] and position.underlyingSymbol() in stats.touchedEMAs and stats.touchedEMAs[position.underlyingSymbol()]:
            score += 1
            reason = "HOD/LOD"

        # Assign a score of 2 if the position reached 50% profit and is now at break-even or slight loss
        if position.orderTag in self.reachedHalfProfit and self.reachedHalfProfit[position.orderTag] and position.positionPnL <= 0:
            score += 2
            reason = "Reached 50% profit"

        # Return True if the total score is 3 or more
        if score >= 3:
            return True, reason

        return False, ""

    def preManageRisk(self):
        # Check if it's time to plot and return if it's not the 1 hour mark
        if self.context.Time.minute % 60 != 0:
            return
        
        # Plot ATR Levels on the "Underlying Price" chart
        for i, level in enumerate(self.ATRLevels.BullLevels()[:3]):
            self.context.Plot("Underlying Price", f"Bull Level {i+1}", level)
        for i, level in enumerate(self.ATRLevels.BearLevels()[:3]):
            self.context.Plot("Underlying Price", f"Bear Level {i+1}", level)

        
        # Loop through all open positions
        for _orderTag, orderId in list(self.context.openPositions.items()):
            # Get the book position
            bookPosition = self.context.allPositions[orderId]

            # TODO: 
            #   if price is 1.0x premium received, increase position quantity
            #   if price is 1.5x premium received, increase position quantity



        return super().preManageRisk()

    def on5MinuteData(self, sender: object, consolidated_bar: TradeBar) -> None:
        """
        On a new 5m bar we check if we should close the position.
        """
        # pass
        for _orderTag, orderId in list(self.context.openPositions.items()):
            # Get the book position
            bookPosition = self.context.allPositions[orderId]

        #     if bookPosition.strategyId == "IronCondor":
        #         continue

        #     self.handleHODLOD(bookPosition, consolidated_bar)

            self.handleFiveMinuteITM(bookPosition, consolidated_bar)

    def on15MinuteData(self, sender: object, consolidated_bar: TradeBar) -> None:
        for _orderTag, orderId in list(self.context.openPositions.items()):
            # Get the book position
            bookPosition = self.context.allPositions[orderId]

            if bookPosition.strategyId == "IronCondor":
                continue

            self.handleHODLOD(bookPosition, consolidated_bar)
        # pass

    def OnSecuritiesChanged(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
        super().OnSecuritiesChanged(algorithm, changes)
        for security in changes.AddedSecurities:
            if security.Type != SecurityType.Equity and security.Type != SecurityType.Index:
                continue
            self.context.logger.info(f"Adding consolidator for {security.Symbol}")
            
            self.consolidators[security.Symbol] = []
            # Creating a 5-minute consolidator.
            consolidator5m = TradeBarConsolidator(timedelta(minutes=5))
            consolidator5m.DataConsolidated += self.on5MinuteData
            self.context.SubscriptionManager.AddConsolidator(security.Symbol, consolidator5m)
            self.consolidators[security.Symbol].append(consolidator5m)
            # Creating a 15-minute consolidator.
            consolidator15m = TradeBarConsolidator(timedelta(minutes=15))
            consolidator15m.DataConsolidated += self.on15MinuteData
            self.context.SubscriptionManager.AddConsolidator(security.Symbol, consolidator15m)
            self.consolidators[security.Symbol].append(consolidator15m)
            # Creating the Daily ATRLevels indicator
            self.context.RegisterIndicator(security.Symbol, self.ATRLevels, Resolution.Daily)
            self.context.WarmUpIndicator(security.Symbol, self.ATRLevels, Resolution.Daily)

            # Creating the EMAs
            for period in self.EMAs.keys():
                ema = ExponentialMovingAverage(period)
                self.EMAs[period][security.Symbol] = ema
                self.context.RegisterIndicator(security.Symbol, ema, consolidator15m)
            
            # Creating the Standard Deviation indicator
            # self.stdDevs[security.Symbol] = StandardDeviation(20)
            # self.context.RegisterIndicator(security.Symbol, self.stdDevs[security.Symbol], consolidator5m)

        
        # NOTE: commented out as for some reason in the middle of the backtest SPX is removed from the universe???!
        # for security in changes.RemovedSecurities:
        #     if security.Type != SecurityType.Equity and security.Type != SecurityType.Index:
        #         continue
        #     if security.Symbol not in self.consolidators:
        #         continue
        #     self.context.logger.info(f"Removing consolidator for {security.Symbol}")
        #     consolidator = self.consolidators.pop(security.Symbol)
        #     self.context.SubscriptionManager.RemoveConsolidator(security.Symbol, consolidator)
        #     consolidator.DataConsolidated -= self.onFiveMinuteData

    def handleHODLOD(self, bookPosition, consolidated_bar):
        stats = bookPosition.strategy.stats
        # Get the high/low of the day before the update
        highOfDay = stats.highOfTheDay
        lowOfDay = stats.lowOfTheDay
        # currentDay = self.context.Time.date()   
        
        if bookPosition.orderTag not in self.triggerHODLOD:
            self.triggerHODLOD[bookPosition.orderTag] = RollingWindow[bool](2) # basically wait 25 minutes before triggering

        if bookPosition.strategyId == 'CallCreditSpread' and consolidated_bar.Close > highOfDay:
            self.triggerHODLOD[bookPosition.orderTag].Add(True)
        elif bookPosition.strategyId == "PutCreditSpread" and consolidated_bar.Close < lowOfDay:
            self.triggerHODLOD[bookPosition.orderTag].Add(True)

        if bookPosition.orderTag in self.triggerHODLOD:
            # Check if all values are True and the RollingWindow is full
            if all(self.triggerHODLOD[bookPosition.orderTag]) and self.triggerHODLOD[bookPosition.orderTag].IsReady:
                self.HODLOD[bookPosition.orderTag] = True
        

    def handleFiveMinuteITM(self, bookPosition, consolidated_bar):
        soldLeg = [leg for leg in bookPosition.legs if leg.isSold][0]

        # Check if we should close the position
        if bookPosition.strategyId == 'CallCreditSpread' and consolidated_bar.Close > soldLeg.strike:
            self.fiveMinuteITM[bookPosition.orderTag] = True
        elif bookPosition.strategyId == "PutCreditSpread" and consolidated_bar.Close < soldLeg.strike:
            self.fiveMinuteITM[bookPosition.orderTag] = True
        
#region imports
from AlgorithmImports import *
#endregion

from .Base import Base


class HedgeRiskManagementModel(Base):
    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
#region imports
from AlgorithmImports import *
#endregion

from .Base import Base


class NoStopLossModel(Base):
    DEFAULT_PARAMETERS = {
        # The frequency (in minutes) with which each position is managed
        "managePositionFrequency": 1,
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        "profitTarget": 0.9,
        # Stop Loss Multiplier, expressed as a function of the profit target (rather than the credit received)
        # The position is closed (Market Order) if:
        #    Position P&L < -abs(openPremium) * stopLossMultiplier
        # where:
        #  - openPremium is the premium received (positive) in case of credit strategies
        #  - openPremium is the premium paid (negative) in case of debit strategies
        #
        # Credit Strategies (i.e. $2 credit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $1 profit)
        #  - stopLossMultiplier = 2 * profitTarget (i.e. -abs(openPremium) * stopLossMultiplier = -abs(2) * 2 * 0.5 = -2 --> stop if P&L < -2$)
        # Debit Strategies (i.e. $4 debit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $2 profit)
        #  - stopLossMultiplier < 1 (You can't lose more than the debit paid. i.e. stopLossMultiplier = 0.6 --> stop if P&L < -2.4$)
        # self.stopLossMultiplier = 3 * self.profitTarget
        # self.stopLossMultiplier = 0.6
        "stopLossMultiplier": None,
        # Ensures that the Stop Loss does not exceed the theoretical loss. (Set to False for Credit Calendars)
        "capStopLoss": True,
    }
    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
#region imports
from AlgorithmImports import *
#endregion

from .Base import Base
from CustomIndicators import ATRLevels
from Tools import Underlying
from Strategy import WorkingOrder

class SPXButterflyMonitor(Base):
    DEFAULT_PARAMETERS = {
        # The frequency (in minutes) with which each position is managed
        "managePositionFrequency": 5,
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        "profitTarget": 1,
        # Stop Loss Multiplier, expressed as a function of the profit target (rather than the credit received)
        # The position is closed (Market Order) if:
        #    Position P&L < -abs(openPremium) * stopLossMultiplier
        # where:
        #  - openPremium is the premium received (positive) in case of credit strategies
        #  - openPremium is the premium paid (negative) in case of debit strategies
        #
        # Credit Strategies (i.e. $2 credit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $1 profit)
        #  - stopLossMultiplier = 2 * profitTarget (i.e. -abs(openPremium) * stopLossMultiplier = -abs(2) * 2 * 0.5 = -2 --> stop if P&L < -2$)
        # Debit Strategies (i.e. $4 debit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $2 profit)
        #  - stopLossMultiplier < 1 (You can't lose more than the debit paid. i.e. stopLossMultiplier = 0.6 --> stop if P&L < -2.4$)
        # self.stopLossMultiplier = 3 * self.profitTarget
        # self.stopLossMultiplier = 0.6
        "stopLossMultiplier": 1.9,
        # Ensures that the Stop Loss does not exceed the theoretical loss. (Set to False for Credit Calendars)
        "capStopLoss": True,
    }

    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
        self.fiveMinuteITM = {}
        self.HODLOD = {}
        self.triggerHODLOD = {}
        # The dictionary of consolidators
        self.consolidators = dict()
        # self.ATRLevels = ATRLevels("ATRLevels", length = 14)
        # EMAs for the 8, 21 and 34 periods
        self.EMAs = {8: {}, 21: {}, 34: {}}
        # self.stdDevs = {}
        # Add a dictionary to keep track of whether the position reached 50% profit
        self.reachedHalfProfit = {}

    def monitorPosition(self, position):
        pass

    def shouldClose(self, position):
        """
        TODO:
        - check if ATR indicator has been breached and exit
        - check if half premium was reached and close 50%-80% of position (not really possible now as we have a True/False return)
        """
        score = 0
        reason = ""
        stats = position.strategy.stats

        # Assign a score of 3 if 5m ITM threshold is met
        # if position.orderTag in self.fiveMinuteITM and self.fiveMinuteITM[position.orderTag]:
        #     score += 3
            # reason = "5m ITM"

        # Assign a score of 1 if HOD/LOD breach occurs
        # if position.orderTag in self.HODLOD and self.HODLOD[position.orderTag] and position.underlyingSymbol() in stats.touchedEMAs and stats.touchedEMAs[position.underlyingSymbol()]:
        #     score += 1
        #     reason = "HOD/LOD"

        # Assign a score of 2 if the position reached 50% profit and is now at break-even or slight loss
        # if position.orderTag in self.reachedHalfProfit and self.reachedHalfProfit[position.orderTag] and position.positionPnL <= 0:
        #     score += 2
        #     reason = "Reached 50% profit"

        # Return True if the total score is 3 or more
        # if score >= 3:
        #     return True, reason

        return False, ""

    
#region imports
from AlgorithmImports import *
#endregion

from .Base import Base
from CustomIndicators import ATRLevels
from Tools import Underlying
from Strategy import WorkingOrder

class SPXCondorMonitor(Base):
    DEFAULT_PARAMETERS = {
        # The frequency (in minutes) with which each position is managed
        "managePositionFrequency": 5,
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        "profitTarget": 1.2,
        # Stop Loss Multiplier, expressed as a function of the profit target (rather than the credit received)
        # The position is closed (Market Order) if:
        #    Position P&L < -abs(openPremium) * stopLossMultiplier
        # where:
        #  - openPremium is the premium received (positive) in case of credit strategies
        #  - openPremium is the premium paid (negative) in case of debit strategies
        #
        # Credit Strategies (i.e. $2 credit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $1 profit)
        #  - stopLossMultiplier = 2 * profitTarget (i.e. -abs(openPremium) * stopLossMultiplier = -abs(2) * 2 * 0.5 = -2 --> stop if P&L < -2$)
        # Debit Strategies (i.e. $4 debit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $2 profit)
        #  - stopLossMultiplier < 1 (You can't lose more than the debit paid. i.e. stopLossMultiplier = 0.6 --> stop if P&L < -2.4$)
        # self.stopLossMultiplier = 3 * self.profitTarget
        # self.stopLossMultiplier = 0.6
        "stopLossMultiplier": 2,
        # Ensures that the Stop Loss does not exceed the theoretical loss. (Set to False for Credit Calendars)
        "capStopLoss": True,
    }

    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
        self.fiveMinuteITM = {}
        self.HODLOD = {}
        self.triggerHODLOD = {}
        # The dictionary of consolidators
        self.consolidators = dict()
        # self.ATRLevels = ATRLevels("ATRLevels", length = 14)
        # EMAs for the 8, 21 and 34 periods
        self.EMAs = {8: {}, 21: {}, 34: {}}
        # self.stdDevs = {}
        # Add a dictionary to keep track of whether the position reached 50% profit
        self.reachedHalfProfit = {}

    def monitorPosition(self, position):
        pass

    def shouldClose(self, position):
        return False, None

    
#region imports
from AlgorithmImports import *
#endregion

from .Base import Base
from CustomIndicators import ATRLevels
from Tools import Underlying
from Strategy import WorkingOrder

class SPXicMonitor(Base):
    DEFAULT_PARAMETERS = {
        # The frequency (in minutes) with which each position is managed
        "managePositionFrequency": 1,
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        "profitTarget": 1.5,
        # Stop Loss Multiplier, expressed as a function of the profit target (rather than the credit received)
        # The position is closed (Market Order) if:
        #    Position P&L < -abs(openPremium) * stopLossMultiplier
        # where:
        #  - openPremium is the premium received (positive) in case of credit strategies
        #  - openPremium is the premium paid (negative) in case of debit strategies
        #
        # Credit Strategies (i.e. $2 credit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $1 profit)
        #  - stopLossMultiplier = 2 * profitTarget (i.e. -abs(openPremium) * stopLossMultiplier = -abs(2) * 2 * 0.5 = -2 --> stop if P&L < -2$)
        # Debit Strategies (i.e. $4 debit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $2 profit)
        #  - stopLossMultiplier < 1 (You can't lose more than the debit paid. i.e. stopLossMultiplier = 0.6 --> stop if P&L < -2.4$)
        # self.stopLossMultiplier = 3 * self.profitTarget
        # self.stopLossMultiplier = 0.6
        "stopLossMultiplier": 1.2,
        # Ensures that the Stop Loss does not exceed the theoretical loss. (Set to False for Credit Calendars)
        "capStopLoss": True,
    }

    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
        self.fiveMinuteITM = {}
        self.HODLOD = {}
        self.triggerHODLOD = {}
        # The dictionary of consolidators
        self.consolidators = dict()
        # self.ATRLevels = ATRLevels("ATRLevels", length = 14)
        # EMAs for the 8, 21 and 34 periods
        self.EMAs = {8: {}, 21: {}, 34: {}}
        # self.stdDevs = {}
        # Add a dictionary to keep track of whether the position reached 50% profit
        self.reachedHalfProfit = {}

    def monitorPosition(self, position):
        pass

    def shouldClose(self, position):
        return False, None

    
#region imports
from AlgorithmImports import *
#endregion

from .Base import Base


class StopLossModel(Base):
    DEFAULT_PARAMETERS = {
        # The frequency (in minutes) with which each position is managed
        "managePositionFrequency": 1,
        # Profit Target Factor (Multiplier of the premium received/paid when the position was opened)
        "profitTarget": 0.7,
        # Stop Loss Multiplier, expressed as a function of the profit target (rather than the credit received)
        # The position is closed (Market Order) if:
        #    Position P&L < -abs(openPremium) * stopLossMultiplier
        # where:
        #  - openPremium is the premium received (positive) in case of credit strategies
        #  - openPremium is the premium paid (negative) in case of debit strategies
        #
        # Credit Strategies (i.e. $2 credit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $1 profit)
        #  - stopLossMultiplier = 2 * profitTarget (i.e. -abs(openPremium) * stopLossMultiplier = -abs(2) * 2 * 0.5 = -2 --> stop if P&L < -2$)
        # Debit Strategies (i.e. $4 debit):
        #  - profitTarget < 1 (i.e. 0.5 -> 50% profit target -> $2 profit)
        #  - stopLossMultiplier < 1 (You can't lose more than the debit paid. i.e. stopLossMultiplier = 0.6 --> stop if P&L < -2.4$)
        # self.stopLossMultiplier = 3 * self.profitTarget
        # self.stopLossMultiplier = 0.6
        "stopLossMultiplier": 2.0,
        # Ensures that the Stop Loss does not exceed the theoretical loss. (Set to False for Credit Calendars)
        "capStopLoss": True,
    }
    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
#region imports
from AlgorithmImports import *
#endregion


# Your New Python File
from .HedgeRiskManagementModel import HedgeRiskManagementModel
from .NoStopLossModel import NoStopLossModel
from .StopLossModel import StopLossModel
from .FPLMonitorModel import FPLMonitorModel
from .SPXicMonitor import SPXicMonitor
from .CCMonitor import CCMonitor
from .SPXButterflyMonitor import SPXButterflyMonitor
from .SPXCondorMonitor import SPXCondorMonitor

#region imports
from AlgorithmImports import *
#endregion

from Tools import Helper

# https://www.quantconnect.com/docs/v2/writing-algorithms/algorithm-framework/portfolio-construction/key-concepts
# Portfolio construction scaffolding class; basic method args.
class Base(PortfolioConstructionModel):
    def __init__(self, context):
        self.context = context
        self.context.logger.debug(f"{self.__class__.__name__} -> __init__")

    # Create list of PortfolioTarget objects from Insights
    def CreateTargets(self, algorithm: QCAlgorithm, insights: List[Insight]) -> List[PortfolioTarget]:
        # super().CreateTargets(algorithm, insights)
        targets = []
        for insight in insights:
            self.context.logger.debug(f'Insight: {insight.Id}')
            # Let's find the order that this insight belongs to
            order = Helper().findIn(
                self.context.workingOrders.values(),
                lambda v: any(i.Id == insight.Id for i in v.insights))

            position = self.context.allPositions[order.orderId]

            target = PortfolioTarget(insight.Symbol, insight.Direction * position.orderQuantity)
            self.context.logger.debug(f'Target: {target.Symbol} {target.Quantity}')
            order.targets.append(target)
            targets.append(target)
        return targets

    # Determines if the portfolio should rebalance based on the provided rebalancing func
    # def IsRebalanceDue(self, insights: List[Insight], algorithmUtc: datetime) -> bool:
    #     return True

    # # Determines the target percent for each insight
    # def DetermineTargetPercent(self, activeInsights: List[Insight]) -> Dict[Insight, float]:
    #     return {}

    # # Gets the target insights to calculate a portfolio target percent for, they will be piped to DetermineTargetPercent()
    # def GetTargetInsights(self) -> List[Insight]:
    #     return []

    # # Determine if the portfolio construction model should create a target for this insight
    # def ShouldCreateTargetForInsight(self, insight: Insight) -> bool:
    #     return True

    # OPTIONAL: Security change details
    # def OnSecuritiesChanged(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
    #     # Security additions and removals are pushed here.
    #     # This can be used for setting up algorithm state.
    #     # changes.AddedSecurities:
    #     # changes.RemovedSecurities:
    #     pass
#region imports
from AlgorithmImports import *
#endregion

from .Base import Base


class OptionsPortfolioConstruction(Base):
    def __init__(self, context):
        # Call the Base class __init__ method
        super().__init__(context)
#region imports
from AlgorithmImports import *
#endregion

# https://www.quantconnect.com/docs/v2/writing-algorithms/algorithm-framework/portfolio-construction/key-concepts
# Portfolio construction scaffolding class; basic method args.
class OptionsPortfolioConstructionModel(PortfolioConstructionModel):
    def __init__(self, context):
        pass

    # Create list of PortfolioTarget objects from Insights
    def CreateTargets(self, algorithm: QCAlgorithm, insights: List[Insight]) -> List[PortfolioTarget]:
        return []

    # Determines if the portfolio should rebalance based on the provided rebalancing func
    def IsRebalanceDue(self, insights: List[Insight], algorithmUtc: datetime) -> bool:
        return True

    # Determines the target percent for each insight
    def DetermineTargetPercent(self, activeInsights: List[Insight]) -> Dict[Insight, float]:
        return {}

    # Gets the target insights to calculate a portfolio target percent for, they will be piped to DetermineTargetPercent()
    def GetTargetInsights(self) -> List[Insight]:
        return []

    # Determine if the portfolio construction model should create a target for this insight
    def ShouldCreateTargetForInsight(self, insight: Insight) -> bool:
        return True

    # OPTIONAL: Security change details
    def OnSecuritiesChanged(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
        # Security additions and removals are pushed here.
        # This can be used for setting up algorithm state.
        # changes.AddedSecurities:
        # changes.RemovedSecurities:
        pass
#region imports
from AlgorithmImports import *
#endregion


# Your New Python File
from .OptionsPortfolioConstruction import OptionsPortfolioConstruction
#region imports
from AlgorithmImports import *
#endregion
import matplotlib.pyplot as plt
import mplfinance
import numpy as np
from pandas.plotting import register_matplotlib_converters
register_matplotlib_converters()

# Your New Python File
class Charting:
    def __init__(self, data, symbol = None):
        self.data = data
        self.symbol = symbol

    def plot(self):
        mplfinance.plot(self.data,
                type='candle',
                style='charles',
                title=f'{self.symbol.Value if self.symbol else "General"} OHLC',
                ylabel='Price ($)',
                figratio=(15, 10))
#region imports
from AlgorithmImports import *
#endregion


# Your New Python File
from .Charting import Charting
#region imports
from AlgorithmImports import *
#endregion

import dataclasses
from dataclasses import dataclass, field
from operator import attrgetter
from typing import Dict, List, Optional
from Tools import ContractUtils
import importlib
from Tools import Helper, ContractUtils, Logger, Underlying


"""
Use it like this:

position_key = "some_key"  # Replace with an appropriate key
position_data = Position(orderId="12345", orderTag="SPX_Put", Strategy="CreditPutSpread", StrategyTag="CPS", expiryStr="20220107", openDttm="2022-01-07 09:30:00", openDt="2022-01-07", openDTE=0, targetPremium=500, orderQuantity=1, maxOrderQuantity=5, openOrderMidPrice=10.0, openOrderMidPriceMin=9.0, openOrderMidPriceMax=11.0, openOrderBidAskSpread=1.0, openOrderLimitPrice=10.0, underlyingPriceAtOpen=4500.0)

# Create Leg objects for sold and bought options
sold_put_leg = Leg(leg_type="SoldPut", option_symbol="SPXW220107P4500", quantity=-1, strike=4500, expiry="20220107")
bought_put_leg = Leg(leg_type="BoughtPut", option_symbol="SPXW220107P4490", quantity=1, strike=4490, expiry="20220107")

# Add the Leg objects to the Position's legs attribute
position_data.legs.extend([sold_put_leg, bought_put_leg])

# Add the Position to the self.positions dictionary
self.positions[position_key] = position_data
"""

@dataclass
class _ParentBase:
    # With the __getitem__ and __setitem__ methods here we are transforming the
    # dataclass into a regular dict. This method is to allow getting fields using ["field"]
    def __getitem__(self, key):
        return super().__getattribute__(key)

    def __setitem__(self, key, value):
        return super().__setattr__(key, value)

    r"""Skip default fields in :func:`~dataclasses.dataclass`
    :func:`object representation <repr()>`.
    Notes
    -----
    Credit: Pietro Oldrati, 2022-05-08, Unilicense
    https://stackoverflow.com/a/72161437/1396928
    """
    def	__repr__(self):
        """Omit default fields in object representation."""
        nodef_f_vals = (
            (f.name, attrgetter(f.name)(self))
            for f in dataclasses.fields(self)
            if attrgetter(f.name)(self) != f.default
        )

        nodef_f_repr = ", ".join(f"{name}={value}" for name, value in nodef_f_vals)
        return f"{self.__class__.__name__}({nodef_f_repr})"
    
    # recursive method that checks the fields of each dataclass and calls asdict if we have another dataclass referenced
    # otherwise it just builds a dictionary and assigns the values and keys.
    def asdict(self):
        result = {}
        for f in dataclasses.fields(self):
            fieldValue = attrgetter(f.name)(self)
            if isinstance(fieldValue, dict):
                result[f.name] = {}
                for k, v in fieldValue.items():
                    if hasattr(type(v), "__dataclass_fields__"):
                        result[f.name][k] = v.asdict()
                    else:
                        result[f.name][k] = v
            elif hasattr(type(fieldValue), "__dataclass_fields__"):
                result[f.name] = fieldValue.asdict()
            else:
                if fieldValue != f.default: result[f.name] = fieldValue
        return result


@dataclass
class WorkingOrder(_ParentBase):
    positionKey: str = ""
    insights: List[Insight] = field(default_factory=list)
    targets: List[PortfolioTarget] = field(default_factory=list)
    orderId: str = ""
    strategy: str = "" # Ex: FPLModel actual class
    strategyTag: str = "" # Ex: FPLModel
    orderType: str = ""
    fills: int = 0
    useLimitOrder: bool = True
    limitOrderPrice: float = 0.0
    lastRetry: Optional[datetime.date] = None
    fillRetries: int = 0 # number retries to get a fill

@dataclass
class Leg(_ParentBase):
    key: str = ""
    expiry: Optional[datetime.date] = None
    contractSide: int = 0  # TODO: this one i think would be the one to use instead of self.contractSide
    symbol: str = ""
    quantity: int = 0
    strike: float = 0.0
    contract: OptionContract = None

    # attributes used for order placement
    # orderSide: int  # TODO: also this i'm not sure what it brings as i can use contractSide.
    # orderQuantity: int
    # limitPrice: float

    @property
    def isCall(self):
        return self.contract.Right == OptionRight.Call
    
    @property
    def isPut(self):
        return self.contract.Right == OptionRight.Put
    
    @property
    def isSold(self):
        return self.contractSide == -1
    
    @property
    def isBought(self):
        return self.contractSide == 1


@dataclass
class OrderType(_ParentBase):
    premium: float = 0.0
    fills: int = 0
    limitOrderExpiryDttm: str = ""
    limitOrderPrice: float = 0.0
    bidAskSpread: float = 0.0
    midPrice: float = 0.0
    midPriceMin: float = 0.0
    midPriceMax: float = 0.0
    limitPrice: float = 0.0
    fillPrice: float = 0.0
    openPremium: float = 0.0
    stalePrice: bool = False
    filled: bool = False
    maxLoss: float = 0.0
    transactionIds: List[int] = field(default_factory=list)
    priceProgressList: List[float] = field(default_factory=list)

@dataclass
class Position(_ParentBase):
    """
    The position class should have a structure to hold data and attributes that define it's functionality. Like what the target premium should be or what the slippage should be.
    """
    # These are structural attributes that never change.
    orderId: str = "" # Ex: 1
    orderTag: str = "" # Ex: PutCreditSpread-1
    strategy: str = "" # Ex: FPLModel actual class
    strategyTag: str = "" # Ex: FPLModel
    strategyId: str = "" # Ex: PutCreditSpread, IronCondor
    expiryStr: str = ""
    expiry: Optional[datetime.date] = None
    linkedOrderTag: str = ""
    targetPremium: float = 0.0
    orderQuantity: int = 0
    maxOrderQuantity: int = 0
    targetProfit: Optional[float] = None
    legs: List[Leg] = field(default_factory=list)
    contractSide: Dict[str, int] = field(default_factory=dict)

    # These are attributes that change based on the position's lifecycle.
    # The first set of attributes are set when the position is opened.
    # Attributes that hold data about the order type
    openOrder: OrderType = field(default_factory=OrderType)
    closeOrder: OrderType = field(default_factory=OrderType)

    # Open attributes that will be set when the position is opened.
    openDttm: str = ""
    openDt: str = ""
    openDTE: int = 0
    openOrderMidPrice: float = 0.0
    openOrderMidPriceMin: float = 0.0
    openOrderMidPriceMax: float = 0.0
    openOrderBidAskSpread: float = 0.0
    openOrderLimitPrice: float = 0.0
    openPremium: float = 0.0
    underlyingPriceAtOpen: float = 0.0

    openFilledDttm: float = 0.0
    openStalePrice: bool = False

    # Attributes that hold the current state of the position
    orderMidPrice: float = 0.0
    limitOrderPrice: float = 0.0
    bidAskSpread: float = 0.0
    positionPnL: float = 0.0

    # Close attributes that will be set when the position is closed.
    closeDttm: str = ""
    closeDt: str = ""
    closeDTE: float = float("NaN")
    closeOrderMidPrice: float = 0.0
    closeOrderMidPriceMin: float = 0.0
    closeOrderMidPriceMax: float = 0.0
    closeOrderBidAskSpread: float = float("NaN")
    closeOrderLimitPrice: float = 0.0
    closePremium: float = 0.0
    underlyingPriceAtClose: float = float("NaN")
    underlyingPriceAtOrderClose: float = float("NaN")
    DIT: int = 0  # days in trade

    closeStalePrice: bool = False
    closeReason: List[str] = field(default_factory=list, init=False)

    # Other attributes that will hold the P&L and other stats.
    PnL: float = 0.0
    PnLMin: float = 0.0
    PnLMax: float = 0.0
    PnLMinDIT: float = 0.0
    PnLMaxDIT: float = 0.0

    # Attributes that determine the status of the position.
    orderCancelled: bool = False
    filled: bool = False
    limitOrder: bool = False  # True if we want the order to be a limit order when it is placed.
    priceProgressList: List[float] = field(default_factory=list)

    def underlyingSymbol(self):
        if not self.legs:
            raise ValueError(f"Missing legs/contracts")
        contracts = [v.symbol for v in self.legs]
        return contracts[0].Underlying

    def strategyModule(self):
        try:
            strategy_module = importlib.import_module(f'Alpha.{self.strategy.name}')
            strategy_class = getattr(strategy_module, self.strategy.name)
            return strategy_class
        except (ImportError, AttributeError):
            raise ValueError(f"Unknown strategy: {self.strategy}")

    def strategyParam(self, parameter_name):
        """
        // Create a Position instance
        pos = Position(
            orderId="123",
            orderTag="ABC",
            strategy="TestAlphaModel",
            strategyTag="XYZ",
            expiryStr="2023-12-31"
        )

        // Get targetProfit parameter from the position's strategy
        print(pos.strategyParam('targetProfit'))  // 0.5
        """
        return self.strategyModule().parameter(parameter_name)

    @property
    def isCreditStrategy(self):
        return self.strategyId in ["PutCreditSpread", "CallCreditSpread", "IronCondor", "IronFly", "CreditButterfly", "ShortStrangle", "ShortStraddle", "ShortCall", "ShortPut"]
    
    @property
    def isDebitStrategy(self):
        return self.strategyId in ["DebitButterfly", "ReverseIronFly", "ReverseIronCondor", "CallDebitSpread", "PutDebitSpread", "LongStrangle", "LongStraddle", "LongCall", "LongPut"]

    # Slippage used to set Limit orders
    def getPositionValue(self, context):
        # Start the timer
        context.executionTimer.start()
        contractUtils = ContractUtils(context)

        # Get the amount of credit received to open the position
        openPremium = self.openOrder.premium
        orderQuantity = self.orderQuantity
        slippage = self.strategyParam("slippage")

        # Loop through all legs of the open position
        orderMidPrice = 0.0
        limitOrderPrice = 0.0
        bidAskSpread = 0.0
        for leg in self.legs:
            contract = leg.contract
            # Reverse the original contract side
            orderSide = -self.contractSide[leg.symbol]
            # Compute the Bid-Ask spread
            bidAskSpread += contractUtils.bidAskSpread(contract)
            # Get the latest mid-price
            midPrice = contractUtils.midPrice(contract)
            # Adjusted mid-price (including slippage)
            adjustedMidPrice = midPrice + orderSide * slippage
            # Total order mid-price
            orderMidPrice -= orderSide * midPrice
            # Total Limit order mid-price (including slippage)
            limitOrderPrice -= orderSide * adjustedMidPrice

            # Add the parameters needed to place a Market/Limit order if needed
            leg.orderSide = orderSide
            leg.orderQuantity = orderQuantity
            leg.limitPrice = adjustedMidPrice

        # Check if the mid-price is positive: avoid closing the position if the Bid-Ask spread is too wide (more than 25% of the credit received)
        positionPnL = openPremium + orderMidPrice * orderQuantity
        if self.strategyParam("validateBidAskSpread") and bidAskSpread > self.strategyParam("bidAskSpreadRatio") * openPremium:
            context.logger.trace(f"The Bid-Ask spread is too wide. Open Premium: {openPremium},  Mid-Price: {orderMidPrice},  Bid-Ask Spread: {bidAskSpread}")
            positionPnL = None

        # Store the full mid-price of the position
        self.orderMidPrice = orderMidPrice
        # Store the Limit Order mid-price of the position (including slippage)
        self.limitOrderPrice = limitOrderPrice
        # Store the full bid-ask spread of the position
        self.bidAskSpread = bidAskSpread
        # Store the position PnL
        self.positionPnL = positionPnL

        # Stop the timer
        context.executionTimer.stop()

    def updateStats(self, context, orderType):
        underlying = Underlying(context, self.underlyingSymbol())
        # If we do use combo orders then we might not need to do this check as it has the midPrice in there.
        # Store the price of the underlying at the time of submitting the Market Order
        self[f"underlyingPriceAt{orderType.title()}"] = underlying.Close()

    def updateOrderStats(self, context, orderType):
        # Start the timer
        context.executionTimer.start()

        # leg = next((leg for leg in self.legs if contract.Symbol == leg.symbol), None)
        # Get the side of the contract at the time of opening: -1 -> Short   +1 -> Long
        # contractSide = leg.contractSide
        contractUtils = ContractUtils(context)

        # Get the contracts
        contracts = [v.contract for v in self.legs]

        # Get the slippage
        slippage = self.strategyParam("slippage") or 0.0

        # Sign of the order: open -> 1 (use orderSide as is),  close -> -1 (reverse the orderSide)
        orderSign = 2*int(orderType == "open")-1
        # Sign of the transaction: open -> -1,  close -> +1
        transactionSign = -orderSign
        # Get the mid price of each contract
        prices = np.array(list(map(contractUtils.midPrice, contracts)))
        # Get the order sides
        orderSides = np.array([c.contractSide for c in self.legs])
        # Total slippage
        totalSlippage = sum(abs(orderSides)) * slippage
        # Compute the total order price (including slippage)
        # This calculates the sum of contracts midPrice so the midPrice difference between contracts.
        midPrice = transactionSign * sum(orderSides * prices) - totalSlippage
        # Compute Bid-Ask spread
        bidAskSpread = sum(list(map(contractUtils.bidAskSpread, contracts)))

        # Store the Open/Close Fill Price (if specified)
        closeFillPrice = self.closeOrder.fillPrice
        order = self[f"{orderType}Order"]
        # Keep track of the Limit order mid-price range
        order.midPriceMin = min(order.midPriceMin, midPrice)
        order.midPriceMax = max(order.midPriceMax, midPrice)
        order.midPrice = midPrice
        order.bidAskSpread = bidAskSpread

        # Exit if we don't need to include the details
        # if not self.strategyParam("includeLegDetails") or context.Time.minute % self.strategyParam("legDatailsUpdateFrequency") != 0:
        #     return

        # # Get the EMA memory factor
        # emaMemory = self.strategyParam("emaMemory")
        # # Compute the decay such that the contribution of each new value drops to 5% after emaMemory iterations
        # emaDecay = 0.05**(1.0/emaMemory)

        # # Update the counter (used for the average)
        # bookPosition["statsUpdateCount"] += 1
        # statsUpdateCount = bookPosition["statsUpdateCount"]

        # # Compute the Greeks (retrieve it as a dictionary)
        # greeks = self.bsm.computeGreeks(contract).__dict__
        # # Add the midPrice and PnL values to the greeks dictionary to generalize the processing loop
        # greeks["midPrice"] = midPrice

        # # List of variables for which we are going to update the stats
        # #vars = ["midPrice", "Delta", "Gamma", "Vega", "Theta", "Rho", "Vomma", "Elasticity", "IV"]
        # vars = [var.title() for var in self.strategyParam("greeksIncluded")] + ["midPrice", "IV"]

        # Get the fill price at the open
        openFillPrice = self.openOrder.fillPrice
        # Check if the fill price is set
        if not math.isnan(openFillPrice):
            # Compute the PnL of position. openPremium will be positive for credit and closePremium will be negative so we just add them together.
            self.PnL = self.openPremium + self.closePremium

            # Add the PnL to the list of variables for which we want to update the stats
            # vars.append("PnL")
            # greeks["PnL"] = PnL

        # for var in vars:
        #     # Set the name of the field to be updated
        #     fieldName = f"{fieldPrefix}.{var}"
        #     strategyLeg = positionStrategyLeg[var]
        #     # Get the latest value from the dictionary
        #     fieldValue = greeks[var]
        #     # Special case for the PnL
        #     if var == "PnL" and statsUpdateCount == 2:
        #         # Initialize the EMA for the PnL
        #         strategyLeg.EMA = fieldValue
        #     # Update the Min field
        #     strategyLeg.Min = min(strategyLeg.Min, fieldValue)
        #     # Update the Max field
        #     strategyLeg.Max = max(strategyLeg.Max, fieldValue)
        #     # Update the Close field (this is the most recent value of the greek)
        #     strategyLeg.Close = fieldValue
        #     # Update the EMA field (IMPORTANT: this must be done before we update the Avg field!)
        #     strategyLeg.EMA = emaDecay * strategyLeg.EMA + (1-emaDecay)*fieldValue
        #     # Update the Avg field
        #     strategyLeg.Avg = (strategyLeg.Avg*(statsUpdateCount-1) + fieldValue)/statsUpdateCount
        #     if self.strategyParam("trackLegDetails") and var == "IV":
        #         if context.Time not in context.positionTracking[self.orderId]:
        #             context.positionTracking[self.orderId][context.Time] = {"orderId": self.orderId
        #                                                                 , "Time": context.Time
        #                                                                 }
        #             context.positionTracking[self.orderId][context.Time][fieldName] = fieldValue

        # Stop the timer
        context.executionTimer.stop()

    def updatePnLRange(self, currentDate, positionPnL):
        # How many days has this position been in trade for
        # currentDit = (self.context.Time.date() - bookPosition.openFilledDttm.date()).days
        currentDit = (currentDate - self.openFilledDttm.date()).days
        # Keep track of the P&L range throughout the life of the position (mark the DIT of when the Min/Max PnL occurs)
        if 100 * positionPnL < self.PnLMax:
            self.PnLMinDIT = currentDit
            self.PnLMin = min(self.PnLMin, 100 * positionPnL)
        if 100 * positionPnL > self.PnLMax:
            self.PnLMaxDIT = currentDit
            self.PnLMax = max(self.PnLMax, 100 * positionPnL)

    def expiryLastTradingDay(self, context):
        # Get the last trading day for the given expiration date (in case it falls on a holiday)
        return context.lastTradingDay(self.expiry)

    def expiryMarketCloseCutoffDttm(self, context):
        # Set the date/time threshold by which the position must be closed (on the last trading day before expiration)
        return datetime.combine(self.expiryLastTradingDay(context), self.strategyParam("marketCloseCutoffTime"))

    def cancelOrder(self, context, orderType = 'open', message = ''):
        self.orderCancelled = True
        execOrder = self[f"{orderType}Order"]
        orderTransactionIds = execOrder.transactionIds
        context.logger.info(f"  >>>  CANCEL-----> {orderType} order with message: {message}")
        context.logger.debug("Expired or the limit order was not filled in the allocated time.")
        context.logger.info(f"Cancel {self.orderTag} & Progress of prices: {execOrder.priceProgressList}")
        context.logger.info(f"Position progress of prices: {self.priceProgressList}")
        context.charting.updateStats(self)
        for id in orderTransactionIds:
            context.logger.info(f"Canceling order: {id}")
            ticket = context.Transactions.GetOrderTicket(id)
            ticket.Cancel(f"Cancelled trade: {message}")
#region imports
from AlgorithmImports import *
#endregion


from .Position import Position, Leg, OrderType, WorkingOrder
#region imports
from AlgorithmImports import *
#endregion

########################################################################################
#                                                                                      #
# Licensed under the Apache License, Version 2.0 (the "License");                      #
# you may not use this file except in compliance with the License.                     #
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0   #
#                                                                                      #
# Unless required by applicable law or agreed to in writing, software                  #
# distributed under the License is distributed on an "AS IS" BASIS,                    #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.             #
# See the License for the specific language governing permissions and                  #
# limitations under the License.                                                       #
#                                                                                      #
# Copyright [2021] [Rocco Claudio Cannizzaro]                                          #
#                                                                                      #
########################################################################################

import numpy as np
from math import *
from scipy import optimize
from scipy.stats import norm
from Tools import Logger, ContractUtils


class BSM:
    def __init__(self, context, tradingDays = 365.0):
        # Set the context
        self.context = context
        # Set the logger
        self.logger = Logger(context, className = type(self).__name__, logLevel = context.logLevel)
        # Initialize the contract utils
        self.contractUtils = ContractUtils(context)
        # Set the IR
        self.riskFreeRate = context.riskFreeRate
        # Set the number of trading days
        self.tradingDays = tradingDays

    def isITM(self, contract, spotPrice = None):
        # Get the current price of the underlying unless otherwise specified
        if spotPrice == None:
            spotPrice = self.contractUtils.getUnderlyingLastPrice(contract)

        if contract.Right == OptionRight.Call:
            # A Call option is in the money if the underlying price is above the strike price
            return contract.Strike < spotPrice
        else:
            # A Put option is in the money if the underlying price is below the strike price
            return spotPrice < contract.Strike

    def bsmD1(self, contract, sigma, tau = None, ir = None, spotPrice = None, atTime = None):
        # Get the DTE as a fraction of a year
        if tau == None:
            tau = self.optionTau(contract, atTime = atTime)

        # Use the risk free rate unless otherwise specified
        if ir == None:
            ir = self.riskFreeRate

        # Get the current price of the underlying unless otherwise specified
        if spotPrice == None:
            spotPrice = self.contractUtils.getUnderlyingLastPrice(contract)

        # Strike price
        strikePrice = contract.Strike

        # Check edge cases:
        #  - The contract is expired -> tau = 0
        #  - The IV could not be computed (deep ITM or far OTM options) -> sigma = 0
        if tau == 0 or sigma == 0:
            # Set the sign based on whether it is a Call (+1) or a Put (-1)
            sign = 2*int(contract.Right == OptionRight.Call)-1
            if(self.isITM(contract, spotPrice = spotPrice)):
                # Deep ITM options:
                #  - Call: d1 = Inf -> Delta = Norm.CDF(d1) = 1
                #  - Put: d1 = -Inf -> Delta = -Norm.CDF(-d1) = -1
                d1 = sign * float('inf')
            else:
                # Far OTM options:
                #  - Call: d1 = -Inf -> Delta = Norm.CDF(d1) = 0
                #  - Put: d1 = Inf -> Delta = -Norm.CDF(-d1) = 0
                d1 = sign * float('-inf')
        else:
            d1 = (np.log(spotPrice/strikePrice) + (ir + 0.5*sigma**2)*tau)/(sigma * np.sqrt(tau))
        return d1

    def bsmD2(self, contract, sigma, tau = None, d1 = None, ir = None, spotPrice = None, atTime = None):

        # Get the DTE as a fraction of a year
        if tau == None:
            tau = self.optionTau(contract, atTime = atTime)

        if d1 == None:
            d1 = self.bsmD1(contract, sigma, tau = tau, ir = ir, spotPrice = spotPrice)

        # Compute D2
        d2 = d1 - sigma * np.sqrt(tau)
        return d2

    # Compute the DTE as a time fraction of the year
    def optionTau(self, contract, atTime = None):
        if atTime == None:
            atTime = self.context.Time
        # Get the expiration date and add 16 hours to the market close
        expiryDttm = contract.Expiry + timedelta(hours = 16)
        # Time until market close
        timeDiff = expiryDttm - atTime
        # Days to expiration: use the fraction of minutes until market close in case of 0-DTE (390 minutes = 6.5h -> from 9:30 to 16:00)
        dte = max(0, timeDiff.days, timeDiff.seconds/(60.0*390.0))
        # DTE as a fraction of a year
        tau = dte/self.tradingDays
        return tau

    # Pricing of a European option based on the Black Scholes Merton model (without dividends)
    def bsmPrice(self, contract, sigma, tau = None, ir = None, spotPrice = None, atTime = None):

        # Get the DTE as a fraction of a year
        if tau == None:
            tau = self.optionTau(contract, atTime = atTime)

        # Get the current price of the underlying unless otherwise specified
        if spotPrice == None:
            spotPrice = self.contractUtils.getUnderlyingLastPrice(contract)
        # Compute D1
        d1 = self.bsmD1(contract, sigma, tau = tau, ir = ir, spotPrice = spotPrice)
        # Compute D2
        d2 = self.bsmD2(contract, sigma, tau = tau, d1 = d1, ir = ir, spotPrice = spotPrice)
        # X*e^(-r*tau)
        Xert = contract.Strike * np.exp(-self.riskFreeRate*tau)

        #Price the option
        if contract.Right == OptionRight.Call:
            # Call Option
            theoreticalPrice = norm.cdf(d1)*spotPrice - norm.cdf(d2)*Xert
        else:
            # Put Option
            theoreticalPrice = norm.cdf(-d2)*Xert - norm.cdf(-d1)*spotPrice
        return theoreticalPrice

    # Compute the Theta of an option
    def bsmTheta(self, contract, sigma, tau = None, d1 = None, d2 = None, ir = None, spotPrice = None, atTime = None):
        # Get the DTE as a fraction of a year
        if tau == None:
            tau = self.optionTau(contract, atTime = atTime)
        # Get the current price of the underlying unless otherwise specified
        if spotPrice == None:
            spotPrice = self.contractUtils.getUnderlyingLastPrice(contract)
        # Compute D1
        if d1 == None:
            d1 = self.bsmD1(contract, sigma, tau = tau, ir = ir, spotPrice = spotPrice)
        # Compute D2
        if d2 == None:
            d2 = self.bsmD2(contract, sigma, tau = tau, d1 = d1, ir = ir, spotPrice = spotPrice)
        # -S*N'(d1)*sigma/(2*sqrt(tau))
        SNs = -(spotPrice * norm.pdf(d1) * sigma) / (2.0 * np.sqrt(tau))
        # r*X*e^(-r*tau)
        rXert = self.riskFreeRate * contract.Strike * np.exp(-self.riskFreeRate*tau)
        # Compute Theta (divide by the number of trading days to get a daily Theta value)
        if contract.Right == OptionRight.Call:
            theta = (SNs  -  rXert * norm.cdf(d2))/self.tradingDays
        else:
            theta = (SNs  +  rXert * norm.cdf(-d2))/self.tradingDays
        return theta

    # Compute the Theta of an option
    def bsmRho(self, contract, sigma, tau = None, d1 = None, d2 = None, ir = None, spotPrice = None, atTime = None):
        # Get the DTE as a fraction of a year
        if tau == None:
            tau = self.optionTau(contract, atTime = atTime)
        # Get the current price of the underlying unless otherwise specified
        if spotPrice == None:
            spotPrice = self.contractUtils.getUnderlyingLastPrice(contract)
        # Compute D1
        if d1 == None:
            d1 = self.bsmD1(contract, sigma, tau = tau, ir = ir, spotPrice = spotPrice)
        # Compute D2
        if d2 == None:
            d2 = self.bsmD2(contract, sigma, tau = tau, d1 = d1, ir = ir, spotPrice = spotPrice)
        # tau*X*e^(-r*tau)
        tXert = tau * self.riskFreeRate * contract.Strike * np.exp(-self.riskFreeRate*tau)
        # Compute Theta
        if contract.Right == OptionRight.Call:
            rho = tXert * norm.cdf(d2)
        else:
            rho = -tXert * norm.cdf(-d2)
        return rho

    # Compute the Gamma of an option
    def bsmGamma(self, contract, sigma, tau = None, d1 = None, ir = None, spotPrice = None, atTime = None):
        # Get the DTE as a fraction of a year
        if tau == None:
            tau = self.optionTau(contract, atTime = atTime)
        # Get the current price of the underlying unless otherwise specified
        if spotPrice == None:
            spotPrice = self.contractUtils.getUnderlyingLastPrice(contract)
        # Compute D1
        if d1 == None:
            d1 = self.bsmD1(contract, sigma, tau = tau, ir = ir, spotPrice = spotPrice)
        # Compute Gamma
        if(sigma == 0 or tau == 0):
            gamma = float('inf')
        else:
            gamma = norm.pdf(d1) / (spotPrice * sigma * np.sqrt(tau))
        return gamma

    # Compute the Vega of an option
    def bsmVega(self, contract, sigma, tau = None, d1 = None, ir = None, spotPrice = None, atTime = None):
        # Get the DTE as a fraction of a year
        if tau == None:
            tau = self.optionTau(contract, atTime = atTime)
        # Get the current price of the underlying unless otherwise specified
        if spotPrice == None:
            spotPrice = self.contractUtils.getUnderlyingLastPrice(contract)
        # Compute D1
        if d1 == None:
            d1 = self.bsmD1(contract, sigma, tau = tau, ir = ir, spotPrice = spotPrice)
        # Compute Vega
        vega = spotPrice * norm.pdf(d1) * np.sqrt(tau)
        return vega

    # Compute the Vomma of an option
    def bsmVomma(self, contract, sigma, tau = None, d1 = None, d2 = None, ir = None, spotPrice = None, atTime = None):
        # Get the DTE as a fraction of a year
        if tau == None:
            tau = self.optionTau(contract, atTime = atTime)
        # Get the current price of the underlying unless otherwise specified
        if spotPrice == None:
            spotPrice = self.contractUtils.getUnderlyingLastPrice(contract)
        # Compute D1
        if d1 == None:
            d1 = self.bsmD1(contract, sigma, tau = tau, ir = ir, spotPrice = spotPrice)
        # Compute D2
        if d2 == None:
            d2 = self.bsmD2(contract, sigma, tau = tau, d1 = d1, ir = ir, spotPrice = spotPrice)
        # Compute Vomma
        if(sigma == 0):
            vomma = float('inf')
        else:
            vomma = spotPrice * norm.pdf(d1) * np.sqrt(tau) * d1 * d2 / sigma
        return vomma

    # Compute Implied Volatility from the price of an option
    def bsmIV(self, contract, tau = None, saveIt = False):

        # Start the timer
        self.context.executionTimer.start()

        # Inner function used to compute the root
        def f(sigma, contract, tau):
            return self.bsmPrice(contract, sigma = sigma, tau = tau) - self.contractUtils.midPrice(contract)
        # First order derivative  (Vega)
        def fprime(sigma, contract, tau):
            return self.bsmVega(contract, sigma = sigma, tau = tau)
        # Second order derivative (Vomma)
        def fprime2(sigma, contract, tau):
            return self.bsmVomma(contract, sigma = sigma, tau = tau)

        # Initialize the IV to zero in case anything goes wrong
        IV = 0
        # Initialize the flag to mark whether we were able to find the root
        converged = False

        # Find the root -> Implied Volatility: Use Halley's method
        try:
            # Start the search at the lastest known value for the IV (if previously calculated)
            x0 = 0.1
            if hasattr(contract, "BSMImpliedVolatility"):
                x0 = contract.BSMImpliedVolatility
            sol = optimize.root_scalar(f, x0 = x0, args = (contract, tau), fprime = fprime, fprime2 = fprime2, method = 'halley', xtol = 1e-6)
            # Get the convergence status
            converged = sol.converged
            # Set the IV if we found the root
            if converged:
                IV = sol.root
        except:
            pass

        # Fallback method (Bisection) if Halley's optimization failed
        if not converged:
            # Find the root -> Implied Volatility
            try:
                sol = optimize.root_scalar(f, bracket = [0.0001, 2], args = (contract, tau), xtol = 1e-6)
                # Get the convergence status
                converged = sol.converged
                # Set the IV if we found the root
                if converged:
                    IV = sol.root
            except:
                pass


        # Check if we need to save the IV as an attribute of the contract object
        if saveIt:
            contract.BSMImpliedVolatility = IV

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

        # Return the result
        return IV

    # Compute the Delta of an option
    def bsmDelta(self, contract, sigma, tau = None, d1 = None, ir = None, spotPrice = None, atTime = None):
        if d1 == None:
            if tau == None:
                # Get the DTE as a fraction of a year
                tau = self.optionTau(contract, atTime = atTime)

            # Compute D1
            d1 = self.bsmD1(contract, sigma, tau = tau, ir = ir, spotPrice = spotPrice)
        ### if (d1 == None)

        # Compute option delta (rounded to 2 digits)
        if contract.Right == OptionRight.Call:
            delta = norm.cdf(d1)
        else:
            delta = -norm.cdf(-d1)
        return delta

    def computeGreeks(self, contract, sigma = None, ir = None, spotPrice = None, atTime = None, saveIt = False):
        # Start the timer
        self.context.executionTimer.start("Tools.BSMLibrary -> computeGreeks")

        # Avoid recomputing the Greeks if we have already done it for this time bar
        if hasattr(contract, "BSMGreeks") and contract.BSMGreeks.lastUpdated == self.context.Time:
            return contract.BSMGreeks

        # Get the DTE as a fraction of a year
        tau = self.optionTau(contract, atTime = atTime)

        if sigma == None:
            # Compute Implied Volatility
            sigma = self.bsmIV(contract, tau = tau, saveIt = saveIt)
        ### if (sigma == None)

        # Get the current price of the underlying unless otherwise specified
        if spotPrice == None:
            spotPrice = self.contractUtils.getUnderlyingLastPrice(contract)

        # Compute D1
        d1 = self.bsmD1(contract, sigma, tau = tau, ir = ir, spotPrice = spotPrice)
        # Compute D2
        d2 = self.bsmD2(contract, sigma, tau = tau, d1 = d1, ir = ir, spotPrice = spotPrice)

        # First order derivatives
        delta = self.bsmDelta(contract, sigma = sigma, tau = tau, d1 = d1, ir = ir, spotPrice = spotPrice)
        theta = self.bsmTheta(contract, sigma, tau = tau, d1 = d1, d2 = d2, ir = ir, spotPrice = spotPrice)
        vega = self.bsmVega(contract, sigma, tau = tau, d1 = d1, ir = ir, spotPrice = spotPrice)
        rho = self.bsmRho(contract, sigma, tau = tau, d1 = d1, d2 = d2, ir = ir, spotPrice = spotPrice)

        # Second Order derivatives
        gamma = self.bsmGamma(contract, sigma, tau = tau, d1 = d1, ir = ir, spotPrice = spotPrice)
        vomma = self.bsmVomma(contract, sigma, tau = tau, d1 = d1, d2 = d2, ir = ir, spotPrice = spotPrice)

        # Lambda (a.k.a. elasticity or leverage: the percentage change in option value per percentage change in the underlying price)
        elasticity = delta * np.float64(spotPrice)/np.float64(self.contractUtils.midPrice(contract))


        # Create a Greeks object
        greeks = BSMGreeks(delta = delta
                            , gamma = gamma
                            , vega = vega
                            , theta = theta
                            , rho = rho
                            , vomma = vomma
                            , elasticity = elasticity
                            , IV = sigma
                            , lastUpdated = self.context.Time
                            )

        # Check if we need to save the Greeks as an attribute of the contract object
        if saveIt:
            contract.BSMGreeks = greeks

        # Stop the timer
        self.context.executionTimer.stop("Tools.BSMLibrary -> computeGreeks")

        return greeks


    # Compute and store the Greeks for a list of contracts
    def setGreeks(self, contracts, sigma = None, ir = None):
        # Start the timer
        self.context.executionTimer.start("Tools.BSMLibrary -> setGreeks")

        if isinstance(contracts, list):
            # Loop through all contracts
            for contract in contracts:
                # Get the current price of the underlying
                spotPrice = self.contractUtils.getUnderlyingLastPrice(contract)
                # Compute the Greeks for the contract
                self.computeGreeks(contract, sigma = sigma, ir = ir, spotPrice = spotPrice, saveIt = True)
        else:
            # Get the current price of the underlying
            spotPrice = self.contractUtils.getUnderlyingLastPrice(contracts)
            # Compute the Greeks on a single contract
            self.computeGreeks(contracts, sigma = sigma, ir = ir, spotPrice = spotPrice, saveIt = True)

            # Log the contract details
            self.logger.trace(f"Contract: {contracts.Symbol}")
            self.logger.trace(f"  -> Contract Mid-Price: {self.contractUtils.midPrice(contracts)}")
            self.logger.trace(f"  -> Spot: {spotPrice}")
            self.logger.trace(f"  -> Strike: {contracts.Strike}")
            self.logger.trace(f"  -> Type: {'Call' if contracts.Right == OptionRight.Call else 'Put'}")
            self.logger.trace(f"  -> IV: {contracts.BSMImpliedVolatility}")
            self.logger.trace(f"  -> Delta: {contracts.BSMGreeks.Delta}")
            self.logger.trace(f"  -> Gamma: {contracts.BSMGreeks.Gamma}")
            self.logger.trace(f"  -> Vega: {contracts.BSMGreeks.Vega}")
            self.logger.trace(f"  -> Theta: {contracts.BSMGreeks.Theta}")
            self.logger.trace(f"  -> Rho: {contracts.BSMGreeks.Rho}")
            self.logger.trace(f"  -> Vomma: {contracts.BSMGreeks.Vomma}")
            self.logger.trace(f"  -> Elasticity: {contracts.BSMGreeks.Elasticity}")

        # Stop the timer
        self.context.executionTimer.stop("Tools.BSMLibrary -> setGreeks")

        return

class BSMGreeks:
    def __init__(self, delta = None, gamma = None, vega = None, theta = None, rho = None, vomma = None, elasticity = None, IV = None, lastUpdated = None, precision = 5):
        self.Delta = self.roundIt(delta, precision)
        self.Gamma = self.roundIt(gamma, precision)
        self.Vega = self.roundIt(vega, precision)
        self.Theta = self.roundIt(theta, precision)
        self.Rho = self.roundIt(rho, precision)
        self.Vomma = self.roundIt(vomma, precision)
        self.Elasticity = self.roundIt(elasticity, precision)
        self.IV = self.roundIt(IV, precision)
        self.lastUpdated = lastUpdated

    def roundIt(self, value, precision = None):
        if precision:
            return round(value, precision)
        else:
            return value
#region imports
from AlgorithmImports import *
#endregion

from Tools import Underlying

class Charting:
    def __init__(self, context, openPositions=True, Stats=True, PnL=True, WinLossStats=True, Performance=True, LossDetails=True, totalSecurities=False, Trades=True, Distribution=True):
        self.context = context

        self.resample = datetime.min

        # QUANTCONNECT limitations in terms of charts
        # Tier	            Max Series	Max Data Points per Series
        # Free	            10	        4,000
        # Quant Researcher	10	        8,000
        # Team	            25	        16,000
        # Trading Firm	    25	        32,000
        # Institution	    100	        96,000
        # Max datapoints set to 4000 (free), 8000 (researcher), 16000 (team) (the maximum allowed by QC)
        self.resamplePeriod = (context.EndDate - context.StartDate) / 8_000
        # Max number of series allowed
        self.maxSeries = 10

        self.charts = []

        # Create an object to store all the stats
        self.stats = CustomObject()

        # Store the details about which charts will be plotted (there is a maximum of 10 series per backtest)
        self.stats.plot = CustomObject()
        self.stats.plot.openPositions = openPositions
        self.stats.plot.Stats = Stats
        self.stats.plot.PnL = PnL
        self.stats.plot.WinLossStats = WinLossStats
        self.stats.plot.Performance = Performance
        self.stats.plot.LossDetails = LossDetails
        self.stats.plot.totalSecurities = totalSecurities
        self.stats.plot.Trades = Trades
        self.stats.plot.Distribution = Distribution

        # Initialize performance metrics
        self.stats.won = 0
        self.stats.lost = 0
        self.stats.winRate = 0.0
        self.stats.premiumCaptureRate = 0.0
        self.stats.totalCredit = 0.0
        self.stats.totalDebit = 0.0
        self.stats.PnL = 0.0
        self.stats.totalWinAmt = 0.0
        self.stats.totalLossAmt = 0.0
        self.stats.averageWinAmt = 0.0
        self.stats.averageLossAmt = 0.0
        self.stats.maxWin = 0.0
        self.stats.maxLoss = 0.0
        self.stats.testedCall = 0
        self.stats.testedPut = 0

        totalSecurities = Chart("Total Securities")
        totalSecurities.AddSeries(Series('Total Securities', SeriesType.Line, 0))

        # Setup Charts
        if openPositions:
            activePositionsPlot = Chart('Open Positions')
            activePositionsPlot.AddSeries(Series('Open Positions', SeriesType.Line, ''))
            self.charts.append(activePositionsPlot)

        if Stats:
            statsPlot = Chart('Stats')
            statsPlot.AddSeries(Series('Won', SeriesType.Line, '', Color.Green))
            statsPlot.AddSeries(Series('Lost', SeriesType.Line, '', Color.Red))
            self.charts.append(statsPlot)

        if PnL:
            pnlPlot = Chart('Profit and Loss')
            pnlPlot.AddSeries(Series('PnL', SeriesType.Line, ''))
            self.charts.append(pnlPlot)

        if WinLossStats:
            winLossStatsPlot = Chart('Win and Loss Stats')
            winLossStatsPlot.AddSeries(Series('Average Win', SeriesType.Line, '$', Color.Green))
            winLossStatsPlot.AddSeries(Series('Average Loss', SeriesType.Line, '$', Color.Red))
            self.charts.append(winLossStatsPlot)

        if Performance:
            performancePlot = Chart('Performance')
            performancePlot.AddSeries(Series('Win Rate', SeriesType.Line, '%'))
            performancePlot.AddSeries(Series('Premium Capture', SeriesType.Line, '%'))
            self.charts.append(performancePlot)

        # Loss Details chart. Only relevant in case of credit strategies
        if LossDetails:
            lossPlot = Chart('Loss Details')
            lossPlot.AddSeries(Series('Short Put Tested', SeriesType.Line, ''))
            lossPlot.AddSeries(Series('Short Call Tested', SeriesType.Line, ''))
            self.charts.append(lossPlot)

        if Trades:
            tradesPlot = Chart('Trades')
            tradesPlot.AddSeries(CandlestickSeries('UNDERLYING', '$'))
            tradesPlot.AddSeries(Series("OPEN TRADE", SeriesType.Scatter, "", Color.Green, ScatterMarkerSymbol.Triangle))
            tradesPlot.AddSeries(Series("CLOSE TRADE", SeriesType.Scatter, "", Color.Red, ScatterMarkerSymbol.TriangleDown))
            self.charts.append(tradesPlot)

        if Distribution:
            distributionPlot = Chart('Distribution')
            distributionPlot.AddSeries(Series('Distribution', SeriesType.Bar, ''))
            self.charts.append(distributionPlot)

        # Add the charts to the context
        for chart in self.charts:
            self.context.AddChart(chart)

        # TODO: consider this for strategies.
        # Call the chart initialization method of each strategy (give a chance to setup custom charts)
        # for strategy in self.strategies:
        #     strategy.setupCharts()

        # Add the first data point to the charts
        self.updateCharts()

    def updateUnderlying(self, bar):
        # Add the latest data point to the underlying chart
        # self.context.Plot("UNDERLYING", "UNDERLYING", bar)
        self.context.Plot("Trades", "UNDERLYING", bar)

    def updateCharts(self, symbol=None):
        # Start the timer
        self.context.executionTimer.start()

        # TODO: consider this for strategies.
        # Call the updateCharts method of each strategy (give a chance to update any custom charts)
        # for strategy in self.strategies:
        #     strategy.updateCharts()

        # Exit if there is nothing to update
        if self.context.Time.time() >= time(15, 59, 0):
            return
        
        # self.context.logger.info(f"Time: {self.context.Time}, Resample: {self.resample}")
        # In order to not exceed the maximum number of datapoints, we resample the charts.
        if self.context.Time <= self.resample: return

        self.resample = self.context.Time  + self.resamplePeriod

        plotInfo = self.stats.plot
        
        if plotInfo.Trades:
            # If symbol is defined then we print the symbol data on the chart
            if symbol is not None:
                underlying = Underlying(self.context, symbol)
                self.context.Plot("Trades", "UNDERLYING", underlying.Security().GetLastData())

        if plotInfo.totalSecurities:
            self.context.Plot("Total Securities", "Total Securities", self.context.Securities.Count)
        # Add the latest stats to the plots
        if plotInfo.openPositions:
            self.context.Plot("Open Positions", "Open Positions", self.context.openPositions.Count)
        if plotInfo.Stats:
            self.context.Plot("Stats", "Won", self.stats.won)
            self.context.Plot("Stats", "Lost", self.stats.lost)
        if plotInfo.PnL:
            self.context.Plot("Profit and Loss", "PnL", self.stats.PnL)
        if plotInfo.WinLossStats:
            self.context.Plot("Win and Loss Stats", "Average Win", self.stats.averageWinAmt)
            self.context.Plot("Win and Loss Stats", "Average Loss", self.stats.averageLossAmt)
        if plotInfo.Performance:
            self.context.Plot("Performance", "Win Rate", self.stats.winRate)
            self.context.Plot("Performance", "Premium Capture", self.stats.premiumCaptureRate)
        if plotInfo.LossDetails:
            self.context.Plot("Loss Details", "Short Put Tested", self.stats.testedPut)
            self.context.Plot("Loss Details", "Short Call Tested", self.stats.testedCall)
        if plotInfo.Distribution:
            self.context.Plot("Distribution", "Distribution", 0)

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

    def plotTrade(self, trade, orderType):
        # Start the timer
        self.context.executionTimer.start()

        # Add the trade to the chart
        strikes = []
        for leg in trade.legs:
            if trade.isCreditStrategy:
                if leg.isSold:
                    strikes.append(leg.strike)
            else:
                if leg.isBought:
                    strikes.append(leg.strike)
        # self.context.logger.info(f"plotTrades!! : Strikes: {strikes}")
        if orderType == "open":
            for strike in strikes:
                self.context.Plot("Trades", "OPEN TRADE", strike)
        else:
            for strike in strikes:
                self.context.Plot("Trades", "CLOSE TRADE", strike)
        
        # NOTE: this can not be made because there is a limit of 10 Series on all charts so it will fail!
        # for strike in strikes:
        #     self.context.Plot("Trades", f"TRADE {strike}", strike)

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

    def updateStats(self, closedPosition):
        # Start the timer
        self.context.executionTimer.start()

        orderId = closedPosition.orderId
        # Get the position P&L
        positionPnL = closedPosition.PnL
        # Get the price of the underlying at the time of closing the position
        priceAtClose = closedPosition.underlyingPriceAtClose

        if closedPosition.isCreditStrategy:
            # Update total credit (the position was opened for a credit)
            self.stats.totalCredit += closedPosition.openPremium
            # Update total debit (the position was closed for a debit)
            self.stats.totalDebit += closedPosition.closePremium
        else:
            # Update total credit (the position was closed for a credit)
            self.stats.totalCredit += closedPosition.closePremium
            # Update total debit (the position was opened for a debit)
            self.stats.totalDebit += closedPosition.openPremium

        # Update the total P&L
        self.stats.PnL += positionPnL
        # Update Win/Loss counters
        if positionPnL > 0:
            self.stats.won += 1
            self.stats.totalWinAmt += positionPnL
            self.stats.maxWin = max(self.stats.maxWin, positionPnL)
            self.stats.averageWinAmt = self.stats.totalWinAmt / self.stats.won
        else:
            self.stats.lost += 1
            self.stats.totalLossAmt += positionPnL
            self.stats.maxLoss = min(self.stats.maxLoss, positionPnL)
            self.stats.averageLossAmt = -self.stats.totalLossAmt / self.stats.lost

            # Check if this is a Credit Strategy
            if closedPosition.isCreditStrategy:
                # Get the strikes for the sold contracts
                sold_puts = [leg.strike for leg in closedPosition.legs if leg.isSold and leg.isPut]
                sold_calls = [leg.strike for leg in closedPosition.legs if leg.isSold and leg.isCall]

                if sold_puts and sold_calls:
                    # Get the short put and short call strikes
                    shortPutStrike = min(sold_puts)
                    shortCallStrike = max(sold_calls)

                    # Check if the short Put is in the money
                    if priceAtClose <= shortPutStrike:
                        self.stats.testedPut += 1
                    # Check if the short Call is in the money
                    elif priceAtClose >= shortCallStrike:
                        self.stats.testedCall += 1
                    # Check if the short Put is being tested
                    elif (priceAtClose-shortPutStrike) < (shortCallStrike - priceAtClose):
                        self.stats.testedPut += 1
                    # The short Call is being tested
                    else:
                        self.stats.testedCall += 1

        # Update the Win Rate
        if ((self.stats.won + self.stats.lost) > 0):
            self.stats.winRate = 100*self.stats.won/(self.stats.won + self.stats.lost)

        if self.stats.totalCredit > 0:
            self.stats.premiumCaptureRate = 100*self.stats.PnL/self.stats.totalCredit

        # Trigger an update of the charts
        self.updateCharts()
        self.plotTrade(closedPosition, "close")

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


# Dummy class useful to create empty objects
class CustomObject:
    pass


#region imports
from AlgorithmImports import *
#endregion

from .Logger import Logger


class ContractUtils:
    def __init__(self, context):
        # Set the context
        self.context = context
        # Set the logger
        self.logger = Logger(context, className=type(self).__name__, logLevel=context.logLevel)

    def getUnderlyingPrice(self, symbol):
        security = self.context.Securities[symbol]
        return self.context.GetLastKnownPrice(security).Price

    def getUnderlyingLastPrice(self, contract):
        # Get the context
        context = self.context
        # Get the object from the Securities dictionary if available (pull the latest price), else use the contract object itself
        if contract.UnderlyingSymbol in context.Securities:
            security = context.Securities[contract.UnderlyingSymbol]

        # Check if we have found the security
        if security is not None:
            # Get the last known price of the security
            return context.GetLastKnownPrice(security).Price
        else:
            # Get the UnderlyingLastPrice attribute of the contract
            return contract.UnderlyingLastPrice

    def getSecurity(self, contract):
        # Get the Securities object
        Securities = self.context.Securities
        # Check if we can extract the Symbol attribute
        if hasattr(contract, "Symbol") and contract.Symbol in Securities:
            # Get the security from the Securities dictionary if available (pull the latest price), else use the contract object itself
            security = Securities[contract.Symbol]
        else:
            # Use the contract itself
            security = contract
        return security

    # Returns the mid-price of an option contract
    def midPrice(self, contract):
        security = self.getSecurity(contract)
        return 0.5 * (security.BidPrice + security.AskPrice)
    
    def volume(self, contract):
        security = self.getSecurity(contract)
        return security.Volume
    
    def openInterest(self, contract):
        security = self.getSecurity(contract)
        return security.OpenInterest
    
    def delta(self, contract):
        security = self.getSecurity(contract)
        return security.Delta
    
    def gamma(self, contract):
        security = self.getSecurity(contract)
        return security.Gamma
    
    def theta(self, contract):
        security = self.getSecurity(contract)
        return security.Theta
    
    def vega(self, contract):
        security = self.getSecurity(contract)
        return security.Vega
    
    def rho(self, contract):
        security = self.getSecurity(contract)
        return security.Rho
    
    def bidPrice(self, contract):
        security = self.getSecurity(contract)
        return security.BidPrice
    
    def askPrice(self, contract):
        security = self.getSecurity(contract)
        return security.AskPrice

    def bidAskSpread(self, contract):
        security = self.getSecurity(contract)
        return abs(security.AskPrice - security.BidPrice)
#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('Tools.DataHandler -> SetOptionFilter')
        self.context.logger.debug(f"SetOptionFilter -> universe: {universe}")
        # 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()
        self.context.logger.debug(f"SetOptionFilter -> filteredUniverse: {filteredUniverse}")
        # Stop the timer
        self.context.executionTimer.stop('Tools.DataHandler -> SetOptionFilter')

        return filteredUniverse

    # SECTION BELOW HANDLES OPTION CHAIN PROVIDER METHODS
    def optionChainProviderFilter(self, symbols, min_strike_rank, max_strike_rank, minDte, maxDte):
        self.context.executionTimer.start('Tools.DataHandler -> optionChainProviderFilter')
        self.context.logger.debug(f"optionChainProviderFilter -> symbols count: {len(symbols)}")
        # 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
                          ]
        

        self.context.logger.debug(f"Context Time: {self.context.Time.date()}")
        unique_dates = set(symbol.ID.Date.date() for symbol in symbols)
        self.context.logger.debug(f"Unique symbol dates: {unique_dates}")
        self.context.logger.debug(f"optionChainProviderFilter -> filteredSymbols: {filteredSymbols}")
        
        # 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
                          ]
        self.context.logger.debug(f"optionChainProviderFilter -> selectedSymbols: {selectedSymbols}")
        # 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)
        
        self.context.executionTimer.stop('Tools.DataHandler -> optionChainProviderFilter')
        
        # 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)
        self.context.logger.debug(f"getOptionContracts -> minDte: {minDte}")
        self.context.logger.debug(f"getOptionContracts -> maxDte: {maxDte}")
        # 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
                ]
        self.context.logger.debug(f"getOptionContracts -> number of contracts: {len(contracts) if contracts else 0}")
        # 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


class Helper:
    def findIn(self, data, condition):
        return next((v for v in data if condition(v)), None)
#region imports
from AlgorithmImports import *
#endregion

########################################################################################
#                                                                                      #
# Licensed under the Apache License, Version 2.0 (the "License");                      #
# you may not use this file except in compliance with the License.                     #
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0   #
#                                                                                      #
# Unless required by applicable law or agreed to in writing, software                  #
# distributed under the License is distributed on an "AS IS" BASIS,                    #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.             #
# See the License for the specific language governing permissions and                  #
# limitations under the License.                                                       #
#                                                                                      #
# Copyright [2021] [Rocco Claudio Cannizzaro]                                          #
#                                                                                      #
########################################################################################

import sys
import pandas as pd

class Logger:
    def __init__(self, context, className=None, logLevel=0):
        if logLevel is None:
            logLevel = 0

        self.context = context
        self.className = className
        self.logLevel = logLevel

    def Log(self, msg, trsh=0):
        # Set the class name (if available)
        if self.className is not None:
            className = f"{self.className}."

        # Set the prefix for the message
        if trsh is None or trsh <= 0:
            prefix = "ERROR"
        elif trsh == 1:
            prefix = "WARNING"
        elif trsh == 2:
            prefix = "INFO"
        elif trsh == 3:
            prefix = "DEBUG"
        else:
            prefix = "TRACE"

        if self.logLevel >= trsh:
            self.context.Log(f" {prefix} -> {className}{sys._getframe(2).f_code.co_name}: {msg}")

    def error(self, msg):
        self.Log(msg, trsh=0)

    def warning(self, msg):
        self.Log(msg, trsh=1)

    def info(self, msg):
        self.Log(msg, trsh=2)

    def debug(self, msg):
        self.Log(msg, trsh=3)

    def trace(self, msg):
        self.Log(msg, trsh=4)

    def dataframe(self, data):
        """
        Should be used to print out to the log as an info the data sent as a dictionary via the data.
        """
        if isinstance(data, list):
            columns = list(data[0].keys())
        else:
            columns = list(data.keys())

        df = pd.DataFrame(data, columns=columns)

        if df.shape[0] > 0:
            self.info(f"\n{df.to_string(index=False)}")
#region imports
from AlgorithmImports import *
#endregion


class Performance:
    def __init__(self, context):
        self.context = context
        self.logger = self.context.logger
        self.dailyTracking = datetime.now()
        self.seenSymbols = set()
        self.tradedSymbols = set()
        self.chainSymbols = set()
        self.tradedToday = False
        self.tracking = {}

    def endOfDay(self, symbol):
        day_summary = {
            "Time": (datetime.now() - self.dailyTracking).total_seconds(),
            "Portfolio": len(self.context.Portfolio),
            "Invested": sum(1 for kvp in self.context.Portfolio if kvp.Value.Invested),
            "Seen": len(self.seenSymbols),
            "Traded": len(self.tradedSymbols),
            "Chains": len(self.chainSymbols)
        }
            # Convert Symbol instance to string
        symbol_str = str(symbol)
        
        # Ensure the date is in the tracking dictionary
        date_key = self.context.Time.date()
        if date_key not in self.tracking:
            self.tracking[date_key] = {}
        
        # Ensure the symbol is in the tracking dictionary
        if symbol_str not in self.tracking[date_key]:
            self.tracking[date_key][symbol_str] = {}
        
        # Store the day summary
        self.tracking[date_key][symbol_str] = day_summary
        self.dailyTracking = datetime.now()
        self.tradedToday = False

    def OnOrderEvent(self, orderEvent):
        if orderEvent.Status == OrderStatus.Filled or orderEvent.Status == OrderStatus.PartiallyFilled:
            if orderEvent.Quantity > 0:
                self.logger.trace(f"Filled {orderEvent.Symbol}")
                self.tradedSymbols.add(orderEvent.Symbol)
                self.tradedToday = True
            else:
                self.logger.trace(f"Unwound {orderEvent.Symbol}")

    def OnUpdate(self, data):
        if data.OptionChains:
            for kvp in data.OptionChains:
                chain = kvp.Value  # Access the OptionChain from the KeyValuePair
                self.chainSymbols.update([oc.Symbol for oc in chain])
                if not self.tradedToday:
                    for optionContract in (contract for contract in chain if contract.Symbol not in self.tradedSymbols):
                        self.seenSymbols.add(optionContract.Symbol)

    def show(self, csv=False):
        if csv:
            self.context.Log("Day,Symbol,Time,Portfolio,Invested,Seen,Traded,Chains")
        for day in sorted(self.tracking.keys()):
            for symbol, stats in self.tracking[day].items():
                if csv:
                    self.context.Log(f"{day},{symbol},{stats['Time']},{stats['Portfolio']},{stats['Invested']},{stats['Seen']},{stats['Traded']},{stats['Chains']}")
                else:
                    self.context.Log(f"{day} - {symbol}: {stats}")
#region imports
from AlgorithmImports import *
#endregion
import time as timer
import math
from datetime import timedelta

class Timer:

    performanceTemplate = {
        "calls": 0.0,
        "elapsedMin": float('Inf'),
        "elapsedMean": None,
        "elapsedMax": float('-Inf'),
        "elapsedTotal": 0.0,
        "elapsedLast": None,
        "startTime": None,
    }

    def __init__(self, context):
        self.context = context
        self.performance = {}

    def start(self, methodName=None):
        # Get the name of the calling method
        methodName = methodName or sys._getframe(1).f_code.co_name
        # Get current performance stats
        performance = self.performance.get(methodName, Timer.performanceTemplate.copy())
        # Get the startTime
        performance["startTime"] = timer.perf_counter()
        # Save it back in the dictionary
        self.performance[methodName] = performance

    def stop(self, methodName=None):
        # Get the name of the calling method
        methodName = methodName or sys._getframe(1).f_code.co_name
        # Get current performance stats
        performance = self.performance.get(methodName)
        # Compute the elapsed
        elapsed = timer.perf_counter() - performance["startTime"]
        # Update the stats
        performance["calls"] += 1
        performance["elapsedLast"] = elapsed
        performance["elapsedMin"] = min(performance["elapsedMin"], elapsed)
        performance["elapsedMax"] = max(performance["elapsedMax"], elapsed)
        performance["elapsedTotal"] += elapsed
        performance["elapsedMean"] = performance["elapsedTotal"]/performance["calls"]

    def showStats(self, methodName=None):
        methods = methodName or self.performance.keys()
        total_elapsed = 0.0  # Initialize total elapsed time
        for method in methods:
            performance = self.performance.get(method)
            if performance:
                self.context.Log(f"Execution Stats ({method}):")
                for key in performance:
                    if key != "startTime":
                        if key == "calls" or performance[key] == None:
                            value = performance[key]
                        elif math.isinf(performance[key]):
                            value = None
                        else:
                            value = timedelta(seconds=performance[key])
                        self.context.Log(f"  --> {key}:{value}")
                total_elapsed += performance.get("elapsedTotal", 0)  # Accumulate elapsedTotal
            else:
                self.context.Log(f"There are no execution stats available for method {method}!")
        # Print the total elapsed time over all methods
        self.context.Log("Summary:")
        self.context.Log(f"  --> elapsedTotal: {timedelta(seconds=total_elapsed)}")
#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
#region imports
from AlgorithmImports import *
from .Timer import Timer
from .Logger import Logger
from .ContractUtils import ContractUtils
from .DataHandler import DataHandler
from .Underlying import Underlying
from .BSMLibrary import BSM, BSMGreeks
from .Helper import Helper
from .Charting import Charting
from .Performance import Performance
#endregion

#region imports
from AlgorithmImports import *
#endregion
"""

![xkcd.com/1570](https://imgs.xkcd.com/comics/engineer_syllogism.png)

## Manuals

* [**Quick Start User Guide**](../examples/Quick Start User Guide.html)

## Tutorials

* [Library of Utilities and Composable Base Strategies](../examples/Strategies Library.html)
* [Multiple Time Frames](../examples/Multiple Time Frames.html)
* [**Parameter Heatmap & Optimization**](../examples/Parameter Heatmap &amp; Optimization.html)
* [Trading with Machine Learning](../examples/Trading with Machine Learning.html)

These tutorials are also available as live Jupyter notebooks:
[![Binder](https://mybinder.org/badge_logo.svg)][binder]
[![Google Colab](https://colab.research.google.com/assets/colab-badge.png)][colab]
<br>In Colab, you might have to `!pip install backtesting`.

[binder]: \
    https://mybinder.org/v2/gh/kernc/backtesting.py/master?\
urlpath=lab%2Ftree%2Fdoc%2Fexamples%2FQuick%20Start%20User%20Guide.ipynb
[colab]: https://colab.research.google.com/github/kernc/backtesting.py/

## Example Strategies

* (contributions welcome)


.. tip::
    For an overview of recent changes, see
    [What's New](https://github.com/kernc/backtesting.py/blob/master/CHANGELOG.md).


## FAQ

Some answers to frequent and popular questions can be found on the
[issue tracker](https://github.com/kernc/backtesting.py/issues?q=label%3Aquestion+-label%3Ainvalid)
or on the [discussion forum](https://github.com/kernc/backtesting.py/discussions) on GitHub.
Please use the search!

## License

This software is licensed under the terms of [AGPL 3.0]{: rel=license},
meaning you can use it for any reasonable purpose and remain in
complete ownership of all the excellent trading strategies you produce,
but you are also encouraged to make sure any upgrades to _Backtesting.py_
itself find their way back to the community.

[AGPL 3.0]: https://www.gnu.org/licenses/agpl-3.0.html

# API Reference Documentation
"""
try:
    from ._version import version as __version__
except ImportError:
    __version__ = '?.?.?'  # Package not installed

from .strategy import Strategy  # noqa: F401
from . import lib  # noqa: F401
from ._plotting import set_bokeh_output  # noqa: F401
from .backtesting import Backtest
#region imports
from AlgorithmImports import *
#endregion
import os
import re
import sys
import warnings
from colorsys import hls_to_rgb, rgb_to_hls
from itertools import cycle, combinations
from functools import partial
from typing import Callable, List, Union

import numpy as np
import pandas as pd

from bokeh.colors import RGB
from bokeh.colors.named import (
    lime as BULL_COLOR,
    tomato as BEAR_COLOR
)
from bokeh.plotting import figure as _figure
from bokeh.models import (  # type: ignore
    CrosshairTool,
    CustomJS,
    ColumnDataSource,
    NumeralTickFormatter,
    Span,
    HoverTool,
    Range1d,
    DatetimeTickFormatter,
    WheelZoomTool,
    LinearColorMapper,
)
try:
    from bokeh.models import CustomJSTickFormatter
except ImportError:  # Bokeh < 3.0
    from bokeh.models import FuncTickFormatter as CustomJSTickFormatter  # type: ignore
from bokeh.io import output_notebook, output_file, show
from bokeh.io.state import curstate
from bokeh.layouts import gridplot
from bokeh.palettes import Category10
from bokeh.transform import factor_cmap

from backtesting._util import _data_period, _as_list, _Indicator


IS_JUPYTER_NOTEBOOK = 'JPY_PARENT_PID' in os.environ

if IS_JUPYTER_NOTEBOOK:
    warnings.warn('Jupyter Notebook detected. '
                  'Setting Bokeh output to notebook. '
                  'This may not work in Jupyter clients without JavaScript '
                  'support (e.g. PyCharm, Spyder IDE). '
                  'Reset with `backtesting.set_bokeh_output(notebook=False)`.')
    output_notebook()


def set_bokeh_output(notebook=False):
    """
    Set Bokeh to output either to a file or Jupyter notebook.
    By default, Bokeh outputs to notebook if running from within
    notebook was detected.
    """
    global IS_JUPYTER_NOTEBOOK
    IS_JUPYTER_NOTEBOOK = notebook


def _windos_safe_filename(filename):
    if sys.platform.startswith('win'):
        return re.sub(r'[^a-zA-Z0-9,_-]', '_', filename.replace('=', '-'))
    return filename


def _bokeh_reset(filename=None):
    curstate().reset()
    if filename:
        if not filename.endswith('.html'):
            filename += '.html'
        output_file(filename, title=filename)
    elif IS_JUPYTER_NOTEBOOK:
        curstate().output_notebook()


def colorgen():
    yield from cycle(Category10[10])


def lightness(color, lightness=.94):
    rgb = np.array([color.r, color.g, color.b]) / 255
    h, _, s = rgb_to_hls(*rgb)
    rgb = np.array(hls_to_rgb(h, lightness, s)) * 255.
    return RGB(*rgb)


_MAX_CANDLES = 10_000


def _maybe_resample_data(resample_rule, df, indicators, equity_data, trades):
    if isinstance(resample_rule, str):
        freq = resample_rule
    else:
        if resample_rule is False or len(df) <= _MAX_CANDLES:
            return df, indicators, equity_data, trades

        freq_minutes = pd.Series({
            "1T": 1,
            "5T": 5,
            "10T": 10,
            "15T": 15,
            "30T": 30,
            "1H": 60,
            "2H": 60*2,
            "4H": 60*4,
            "8H": 60*8,
            "1D": 60*24,
            "1W": 60*24*7,
            "1M": np.inf,
        })
        timespan = df.index[-1] - df.index[0]
        require_minutes = (timespan / _MAX_CANDLES).total_seconds() // 60
        freq = freq_minutes.where(freq_minutes >= require_minutes).first_valid_index()
        warnings.warn(f"Data contains too many candlesticks to plot; downsampling to {freq!r}. "
                      "See `Backtest.plot(resample=...)`")

    from .lib import OHLCV_AGG, TRADES_AGG, _EQUITY_AGG
    df = df.resample(freq, label='right').agg(OHLCV_AGG).dropna()

    indicators = [_Indicator(i.df.resample(freq, label='right').mean()
                             .dropna().reindex(df.index).values.T,
                             **dict(i._opts, name=i.name,
                                    # Replace saved index with the resampled one
                                    index=df.index))
                  for i in indicators]
    assert not indicators or indicators[0].df.index.equals(df.index)

    equity_data = equity_data.resample(freq, label='right').agg(_EQUITY_AGG).dropna(how='all')
    assert equity_data.index.equals(df.index)

    def _weighted_returns(s, trades=trades):
        df = trades.loc[s.index]
        return ((df['Size'].abs() * df['ReturnPct']) / df['Size'].abs().sum()).sum()

    def _group_trades(column):
        def f(s, new_index=pd.Index(df.index.view(int)), bars=trades[column]):
            if s.size:
                # Via int64 because on pandas recently broken datetime
                mean_time = int(bars.loc[s.index].view(int).mean())
                new_bar_idx = new_index.get_indexer([mean_time], method='nearest')[0]
                return new_bar_idx
        return f

    if len(trades):  # Avoid pandas "resampling on Int64 index" error
        trades = trades.assign(count=1).resample(freq, on='ExitTime', label='right').agg(dict(
            TRADES_AGG,
            ReturnPct=_weighted_returns,
            count='sum',
            EntryBar=_group_trades('EntryTime'),
            ExitBar=_group_trades('ExitTime'),
        )).dropna()

    return df, indicators, equity_data, trades


def plot(*, results: pd.Series,
         df: pd.DataFrame,
         indicators: List[_Indicator],
         filename='', plot_width=None,
         plot_equity=True, plot_return=False, plot_pl=True,
         plot_volume=True, plot_drawdown=False, plot_trades=True,
         smooth_equity=False, relative_equity=True,
         superimpose=True, resample=True,
         reverse_indicators=True,
         show_legend=True, open_browser=True):
    """
    Like much of GUI code everywhere, this is a mess.
    """
    # We need to reset global Bokeh state, otherwise subsequent runs of
    # plot() contain some previous run's cruft data (was noticed when
    # TestPlot.test_file_size() test was failing).
    if not filename and not IS_JUPYTER_NOTEBOOK:
        filename = _windos_safe_filename(str(results._strategy))
    _bokeh_reset(filename)

    COLORS = [BEAR_COLOR, BULL_COLOR]
    BAR_WIDTH = .8

    assert df.index.equals(results['_equity_curve'].index)
    equity_data = results['_equity_curve'].copy(deep=False)
    trades = results['_trades']

    plot_volume = plot_volume and not df.Volume.isnull().all()
    plot_equity = plot_equity and not trades.empty
    plot_return = plot_return and not trades.empty
    plot_pl = plot_pl and not trades.empty
    is_datetime_index = isinstance(df.index, pd.DatetimeIndex)

    from .lib import OHLCV_AGG
    # ohlc df may contain many columns. We're only interested in, and pass on to Bokeh, these
    df = df[list(OHLCV_AGG.keys())].copy(deep=False)

    # Limit data to max_candles
    if is_datetime_index:
        df, indicators, equity_data, trades = _maybe_resample_data(
            resample, df, indicators, equity_data, trades)

    df.index.name = None  # Provides source name @index
    df['datetime'] = df.index  # Save original, maybe datetime index
    df = df.reset_index(drop=True)
    equity_data = equity_data.reset_index(drop=True)
    index = df.index

    new_bokeh_figure = partial(
        _figure,
        x_axis_type='linear',
        width=plot_width,
        height=400,
        tools="xpan,xwheel_zoom,box_zoom,undo,redo,reset,save",
        active_drag='xpan',
        active_scroll='xwheel_zoom')

    pad = (index[-1] - index[0]) / 20

    _kwargs = dict(x_range=Range1d(index[0], index[-1],
                                   min_interval=10,
                                   bounds=(index[0] - pad,
                                           index[-1] + pad))) if index.size > 1 else {}
    fig_ohlc = new_bokeh_figure(**_kwargs)
    figs_above_ohlc, figs_below_ohlc = [], []

    source = ColumnDataSource(df)
    source.add((df.Close >= df.Open).values.astype(np.uint8).astype(str), 'inc')

    trade_source = ColumnDataSource(dict(
        index=trades['ExitBar'],
        datetime=trades['ExitTime'],
        exit_price=trades['ExitPrice'],
        size=trades['Size'],
        returns_positive=(trades['ReturnPct'] > 0).astype(int).astype(str),
    ))

    inc_cmap = factor_cmap('inc', COLORS, ['0', '1'])
    cmap = factor_cmap('returns_positive', COLORS, ['0', '1'])
    colors_darker = [lightness(BEAR_COLOR, .35),
                     lightness(BULL_COLOR, .35)]
    trades_cmap = factor_cmap('returns_positive', colors_darker, ['0', '1'])

    if is_datetime_index:
        fig_ohlc.xaxis.formatter = CustomJSTickFormatter(
            args=dict(axis=fig_ohlc.xaxis[0],
                      formatter=DatetimeTickFormatter(days='%a, %d %b',
                                                      months='%m/%Y'),
                      source=source),
            code='''
this.labels = this.labels || formatter.doFormat(ticks
                                                .map(i => source.data.datetime[i])
                                                .filter(t => t !== undefined));
return this.labels[index] || "";
        ''')

    NBSP = '\N{NBSP}' * 4  # noqa: E999
    ohlc_extreme_values = df[['High', 'Low']].copy(deep=False)
    ohlc_tooltips = [
        ('x, y', NBSP.join(('$index',
                            '$y{0,0.0[0000]}'))),
        ('OHLC', NBSP.join(('@Open{0,0.0[0000]}',
                            '@High{0,0.0[0000]}',
                            '@Low{0,0.0[0000]}',
                            '@Close{0,0.0[0000]}'))),
        ('Volume', '@Volume{0,0}')]

    def new_indicator_figure(**kwargs):
        kwargs.setdefault('height', 90)
        fig = new_bokeh_figure(x_range=fig_ohlc.x_range,
                               active_scroll='xwheel_zoom',
                               active_drag='xpan',
                               **kwargs)
        fig.xaxis.visible = False
        fig.yaxis.minor_tick_line_color = None
        return fig

    def set_tooltips(fig, tooltips=(), vline=True, renderers=()):
        tooltips = list(tooltips)
        renderers = list(renderers)

        if is_datetime_index:
            formatters = {'@datetime': 'datetime'}
            tooltips = [("Date", "@datetime{%c}")] + tooltips
        else:
            formatters = {}
            tooltips = [("#", "@index")] + tooltips
        fig.add_tools(HoverTool(
            point_policy='follow_mouse',
            renderers=renderers, formatters=formatters,
            tooltips=tooltips, mode='vline' if vline else 'mouse'))

    def _plot_equity_section(is_return=False):
        """Equity section"""
        # Max DD Dur. line
        equity = equity_data['Equity'].copy()
        dd_end = equity_data['DrawdownDuration'].idxmax()
        if np.isnan(dd_end):
            dd_start = dd_end = equity.index[0]
        else:
            dd_start = equity[:dd_end].idxmax()
            # If DD not extending into the future, get exact point of intersection with equity
            if dd_end != equity.index[-1]:
                dd_end = np.interp(equity[dd_start],
                                   (equity[dd_end - 1], equity[dd_end]),
                                   (dd_end - 1, dd_end))

        if smooth_equity:
            interest_points = pd.Index([
                # Beginning and end
                equity.index[0], equity.index[-1],
                # Peak equity and peak DD
                equity.idxmax(), equity_data['DrawdownPct'].idxmax(),
                # Include max dd end points. Otherwise the MaxDD line looks amiss.
                dd_start, int(dd_end), min(int(dd_end + 1), equity.size - 1),
            ])
            select = pd.Index(trades['ExitBar']).union(interest_points)
            select = select.unique().dropna()
            equity = equity.iloc[select].reindex(equity.index)
            equity.interpolate(inplace=True)

        assert equity.index.equals(equity_data.index)

        if relative_equity:
            equity /= equity.iloc[0]
        if is_return:
            equity -= equity.iloc[0]

        yaxis_label = 'Return' if is_return else 'Equity'
        source_key = 'eq_return' if is_return else 'equity'
        source.add(equity, source_key)
        fig = new_indicator_figure(
            y_axis_label=yaxis_label,
            **({} if plot_drawdown else dict(height=110)))

        # High-watermark drawdown dents
        fig.patch('index', 'equity_dd',
                  source=ColumnDataSource(dict(
                      index=np.r_[index, index[::-1]],
                      equity_dd=np.r_[equity, equity.cummax()[::-1]]
                  )),
                  fill_color='#ffffea', line_color='#ffcb66')

        # Equity line
        r = fig.line('index', source_key, source=source, line_width=1.5, line_alpha=1)
        if relative_equity:
            tooltip_format = f'@{source_key}{{+0,0.[000]%}}'
            tick_format = '0,0.[00]%'
            legend_format = '{:,.0f}%'
        else:
            tooltip_format = f'@{source_key}{{$ 0,0}}'
            tick_format = '$ 0.0 a'
            legend_format = '${:,.0f}'
        set_tooltips(fig, [(yaxis_label, tooltip_format)], renderers=[r])
        fig.yaxis.formatter = NumeralTickFormatter(format=tick_format)

        # Peaks
        argmax = equity.idxmax()
        fig.scatter(argmax, equity[argmax],
                    legend_label='Peak ({})'.format(
                        legend_format.format(equity[argmax] * (100 if relative_equity else 1))),
                    color='cyan', size=8)
        fig.scatter(index[-1], equity.values[-1],
                    legend_label='Final ({})'.format(
                        legend_format.format(equity.iloc[-1] * (100 if relative_equity else 1))),
                    color='blue', size=8)

        if not plot_drawdown:
            drawdown = equity_data['DrawdownPct']
            argmax = drawdown.idxmax()
            fig.scatter(argmax, equity[argmax],
                        legend_label='Max Drawdown (-{:.1f}%)'.format(100 * drawdown[argmax]),
                        color='red', size=8)
        dd_timedelta_label = df['datetime'].iloc[int(round(dd_end))] - df['datetime'].iloc[dd_start]
        fig.line([dd_start, dd_end], equity.iloc[dd_start],
                 line_color='red', line_width=2,
                 legend_label=f'Max Dd Dur. ({dd_timedelta_label})'
                 .replace(' 00:00:00', '')
                 .replace('(0 days ', '('))

        figs_above_ohlc.append(fig)

    def _plot_drawdown_section():
        """Drawdown section"""
        fig = new_indicator_figure(y_axis_label="Drawdown")
        drawdown = equity_data['DrawdownPct']
        argmax = drawdown.idxmax()
        source.add(drawdown, 'drawdown')
        r = fig.line('index', 'drawdown', source=source, line_width=1.3)
        fig.scatter(argmax, drawdown[argmax],
                    legend_label='Peak (-{:.1f}%)'.format(100 * drawdown[argmax]),
                    color='red', size=8)
        set_tooltips(fig, [('Drawdown', '@drawdown{-0.[0]%}')], renderers=[r])
        fig.yaxis.formatter = NumeralTickFormatter(format="-0.[0]%")
        return fig

    def _plot_pl_section():
        """Profit/Loss markers section"""
        fig = new_indicator_figure(y_axis_label="Profit / Loss")
        fig.add_layout(Span(location=0, dimension='width', line_color='#666666',
                            line_dash='dashed', line_width=1))
        returns_long = np.where(trades['Size'] > 0, trades['ReturnPct'], np.nan)
        returns_short = np.where(trades['Size'] < 0, trades['ReturnPct'], np.nan)
        size = trades['Size'].abs()
        size = np.interp(size, (size.min(), size.max()), (8, 20))
        trade_source.add(returns_long, 'returns_long')
        trade_source.add(returns_short, 'returns_short')
        trade_source.add(size, 'marker_size')
        if 'count' in trades:
            trade_source.add(trades['count'], 'count')
        r1 = fig.scatter('index', 'returns_long', source=trade_source, fill_color=cmap,
                         marker='triangle', line_color='black', size='marker_size')
        r2 = fig.scatter('index', 'returns_short', source=trade_source, fill_color=cmap,
                         marker='inverted_triangle', line_color='black', size='marker_size')
        tooltips = [("Size", "@size{0,0}")]
        if 'count' in trades:
            tooltips.append(("Count", "@count{0,0}"))
        set_tooltips(fig, tooltips + [("P/L", "@returns_long{+0.[000]%}")],
                     vline=False, renderers=[r1])
        set_tooltips(fig, tooltips + [("P/L", "@returns_short{+0.[000]%}")],
                     vline=False, renderers=[r2])
        fig.yaxis.formatter = NumeralTickFormatter(format="0.[00]%")
        return fig

    def _plot_volume_section():
        """Volume section"""
        fig = new_indicator_figure(y_axis_label="Volume")
        fig.xaxis.formatter = fig_ohlc.xaxis[0].formatter
        fig.xaxis.visible = True
        fig_ohlc.xaxis.visible = False  # Show only Volume's xaxis
        r = fig.vbar('index', BAR_WIDTH, 'Volume', source=source, color=inc_cmap)
        set_tooltips(fig, [('Volume', '@Volume{0.00 a}')], renderers=[r])
        fig.yaxis.formatter = NumeralTickFormatter(format="0 a")
        return fig

    def _plot_superimposed_ohlc():
        """Superimposed, downsampled vbars"""
        time_resolution = pd.DatetimeIndex(df['datetime']).resolution
        resample_rule = (superimpose if isinstance(superimpose, str) else
                         dict(day='M',
                              hour='D',
                              minute='H',
                              second='T',
                              millisecond='S').get(time_resolution))
        if not resample_rule:
            warnings.warn(
                f"'Can't superimpose OHLC data with rule '{resample_rule}'"
                f"(index datetime resolution: '{time_resolution}'). Skipping.",
                stacklevel=4)
            return

        df2 = (df.assign(_width=1).set_index('datetime')
               .resample(resample_rule, label='left')
               .agg(dict(OHLCV_AGG, _width='count')))

        # Check if resampling was downsampling; error on upsampling
        orig_freq = _data_period(df['datetime'])
        resample_freq = _data_period(df2.index)
        if resample_freq < orig_freq:
            raise ValueError('Invalid value for `superimpose`: Upsampling not supported.')
        if resample_freq == orig_freq:
            warnings.warn('Superimposed OHLC plot matches the original plot. Skipping.',
                          stacklevel=4)
            return

        df2.index = df2['_width'].cumsum().shift(1).fillna(0)
        df2.index += df2['_width'] / 2 - .5
        df2['_width'] -= .1  # Candles don't touch

        df2['inc'] = (df2.Close >= df2.Open).astype(int).astype(str)
        df2.index.name = None
        source2 = ColumnDataSource(df2)
        fig_ohlc.segment('index', 'High', 'index', 'Low', source=source2, color='#bbbbbb')
        colors_lighter = [lightness(BEAR_COLOR, .92),
                          lightness(BULL_COLOR, .92)]
        fig_ohlc.vbar('index', '_width', 'Open', 'Close', source=source2, line_color=None,
                      fill_color=factor_cmap('inc', colors_lighter, ['0', '1']))

    def _plot_ohlc():
        """Main OHLC bars"""
        fig_ohlc.segment('index', 'High', 'index', 'Low', source=source, color="black")
        r = fig_ohlc.vbar('index', BAR_WIDTH, 'Open', 'Close', source=source,
                          line_color="black", fill_color=inc_cmap)
        return r

    def _plot_ohlc_trades():
        """Trade entry / exit markers on OHLC plot"""
        trade_source.add(trades[['EntryBar', 'ExitBar']].values.tolist(), 'position_lines_xs')
        trade_source.add(trades[['EntryPrice', 'ExitPrice']].values.tolist(), 'position_lines_ys')
        fig_ohlc.multi_line(xs='position_lines_xs', ys='position_lines_ys',
                            source=trade_source, line_color=trades_cmap,
                            legend_label=f'Trades ({len(trades)})',
                            line_width=8, line_alpha=1, line_dash='dotted')

    def _plot_indicators():
        """Strategy indicators"""

        def _too_many_dims(value):
            assert value.ndim >= 2
            if value.ndim > 2:
                warnings.warn(f"Can't plot indicators with >2D ('{value.name}')",
                              stacklevel=5)
                return True
            return False

        class LegendStr(str):
            # The legend string is such a string that only matches
            # itself if it's the exact same object. This ensures
            # legend items are listed separately even when they have the
            # same string contents. Otherwise, Bokeh would always consider
            # equal strings as one and the same legend item.
            def __eq__(self, other):
                return self is other

        ohlc_colors = colorgen()
        indicator_figs = []

        for i, value in enumerate(indicators):
            value = np.atleast_2d(value)

            # Use .get()! A user might have assigned a Strategy.data-evolved
            # _Array without Strategy.I()
            if not value._opts.get('plot') or _too_many_dims(value):
                continue

            is_overlay = value._opts['overlay']
            is_scatter = value._opts['scatter']
            if is_overlay:
                fig = fig_ohlc
            else:
                fig = new_indicator_figure()
                indicator_figs.append(fig)
            tooltips = []
            colors = value._opts['color']
            colors = colors and cycle(_as_list(colors)) or (
                cycle([next(ohlc_colors)]) if is_overlay else colorgen())
            legend_label = LegendStr(value.name)
            for j, arr in enumerate(value, 1):
                color = next(colors)
                source_name = f'{legend_label}_{i}_{j}'
                if arr.dtype == bool:
                    arr = arr.astype(int)
                source.add(arr, source_name)
                tooltips.append(f'@{{{source_name}}}{{0,0.0[0000]}}')
                if is_overlay:
                    ohlc_extreme_values[source_name] = arr
                    if is_scatter:
                        fig.scatter(
                            'index', source_name, source=source,
                            legend_label=legend_label, color=color,
                            line_color='black', fill_alpha=.8,
                            marker='circle', radius=BAR_WIDTH / 2 * 1.5)
                    else:
                        fig.line(
                            'index', source_name, source=source,
                            legend_label=legend_label, line_color=color,
                            line_width=1.3)
                else:
                    if is_scatter:
                        r = fig.scatter(
                            'index', source_name, source=source,
                            legend_label=LegendStr(legend_label), color=color,
                            marker='circle', radius=BAR_WIDTH / 2 * .9)
                    else:
                        r = fig.line(
                            'index', source_name, source=source,
                            legend_label=LegendStr(legend_label), line_color=color,
                            line_width=1.3)
                    # Add dashed centerline just because
                    mean = float(pd.Series(arr).mean())
                    if not np.isnan(mean) and (abs(mean) < .1 or
                                               round(abs(mean), 1) == .5 or
                                               round(abs(mean), -1) in (50, 100, 200)):
                        fig.add_layout(Span(location=float(mean), dimension='width',
                                            line_color='#666666', line_dash='dashed',
                                            line_width=.5))
            if is_overlay:
                ohlc_tooltips.append((legend_label, NBSP.join(tooltips)))
            else:
                set_tooltips(fig, [(legend_label, NBSP.join(tooltips))], vline=True, renderers=[r])
                # If the sole indicator line on this figure,
                # have the legend only contain text without the glyph
                if len(value) == 1:
                    fig.legend.glyph_width = 0
        return indicator_figs

    # Construct figure ...

    if plot_equity:
        _plot_equity_section()

    if plot_return:
        _plot_equity_section(is_return=True)

    if plot_drawdown:
        figs_above_ohlc.append(_plot_drawdown_section())

    if plot_pl:
        figs_above_ohlc.append(_plot_pl_section())

    if plot_volume:
        fig_volume = _plot_volume_section()
        figs_below_ohlc.append(fig_volume)

    if superimpose and is_datetime_index:
        _plot_superimposed_ohlc()

    ohlc_bars = _plot_ohlc()
    if plot_trades:
        _plot_ohlc_trades()
    indicator_figs = _plot_indicators()
    if reverse_indicators:
        indicator_figs = indicator_figs[::-1]
    figs_below_ohlc.extend(indicator_figs)

    set_tooltips(fig_ohlc, ohlc_tooltips, vline=True, renderers=[ohlc_bars])

    source.add(ohlc_extreme_values.min(1), 'ohlc_low')
    source.add(ohlc_extreme_values.max(1), 'ohlc_high')

    custom_js_args = dict(ohlc_range=fig_ohlc.y_range,
                          source=source)
    if plot_volume:
        custom_js_args.update(volume_range=fig_volume.y_range)

    # fig_ohlc.x_range.js_on_change('end', CustomJS(args=custom_js_args,  # type: ignore
    #                                               code=_AUTOSCALE_JS_CALLBACK))

    plots = figs_above_ohlc + [fig_ohlc] + figs_below_ohlc
    linked_crosshair = CrosshairTool(dimensions='both')

    for f in plots:
        if f.legend:
            f.legend.visible = show_legend
            f.legend.location = 'top_left'
            f.legend.border_line_width = 1
            f.legend.border_line_color = '#333333'
            f.legend.padding = 5
            f.legend.spacing = 0
            f.legend.margin = 0
            f.legend.label_text_font_size = '8pt'
            f.legend.click_policy = "hide"
        f.min_border_left = 0
        f.min_border_top = 3
        f.min_border_bottom = 6
        f.min_border_right = 10
        f.outline_line_color = '#666666'

        f.add_tools(linked_crosshair)
        wheelzoom_tool = next(wz for wz in f.tools if isinstance(wz, WheelZoomTool))
        wheelzoom_tool.maintain_focus = False  # type: ignore

    kwargs = {}
    if plot_width is None:
        kwargs['sizing_mode'] = 'stretch_width'

    fig = gridplot(
        plots,
        ncols=1,
        toolbar_location='right',
        toolbar_options=dict(logo=None),
        merge_tools=True,
        **kwargs  # type: ignore
    )
    show(fig, browser=None if open_browser else 'none')
    return fig


def plot_heatmaps(heatmap: pd.Series, agg: Union[Callable, str], ncols: int,
                  filename: str = '', plot_width: int = 1200, open_browser: bool = True):
    if not (isinstance(heatmap, pd.Series) and
            isinstance(heatmap.index, pd.MultiIndex)):
        raise ValueError('heatmap must be heatmap Series as returned by '
                         '`Backtest.optimize(..., return_heatmap=True)`')

    _bokeh_reset(filename)

    param_combinations = combinations(heatmap.index.names, 2)
    dfs = [heatmap.groupby(list(dims)).agg(agg).to_frame(name='_Value')
           for dims in param_combinations]
    plots = []
    cmap = LinearColorMapper(palette='Viridis256',
                             low=min(df.min().min() for df in dfs),
                             high=max(df.max().max() for df in dfs),
                             nan_color='white')
    for df in dfs:
        name1, name2 = df.index.names
        level1 = df.index.levels[0].astype(str).tolist()
        level2 = df.index.levels[1].astype(str).tolist()
        df = df.reset_index()
        df[name1] = df[name1].astype('str')
        df[name2] = df[name2].astype('str')

        fig = _figure(x_range=level1,
                      y_range=level2,
                      x_axis_label=name1,
                      y_axis_label=name2,
                      width=plot_width // ncols,
                      height=plot_width // ncols,
                      tools='box_zoom,reset,save',
                      tooltips=[(name1, '@' + name1),
                                (name2, '@' + name2),
                                ('Value', '@_Value{0.[000]}')])
        fig.grid.grid_line_color = None
        fig.axis.axis_line_color = None
        fig.axis.major_tick_line_color = None
        fig.axis.major_label_standoff = 0

        fig.rect(x=name1,
                 y=name2,
                 width=1,
                 height=1,
                 source=df,
                 line_color=None,
                 fill_color=dict(field='_Value',
                                 transform=cmap))
        plots.append(fig)

    fig = gridplot(
        plots,  # type: ignore
        ncols=ncols,
        toolbar_options=dict(logo=None),
        toolbar_location='above',
        merge_tools=True,
    )

    show(fig, browser=None if open_browser else 'none')
    return fig
#region imports
from AlgorithmImports import *
#endregion
from typing import TYPE_CHECKING, List, Union

import numpy as np
import pandas as pd

from ._util import _data_period

if TYPE_CHECKING:
    from .strategy import Strategy
    from .trade import Trade


def compute_drawdown_duration_peaks(dd: pd.Series):
    iloc = np.unique(np.r_[(dd == 0).values.nonzero()[0], len(dd) - 1])
    iloc = pd.Series(iloc, index=dd.index[iloc])
    df = iloc.to_frame('iloc').assign(prev=iloc.shift())
    df = df[df['iloc'] > df['prev'] + 1].astype(int)

    # If no drawdown since no trade, avoid below for pandas sake and return nan series
    if not len(df):
        return (dd.replace(0, np.nan),) * 2

    df['duration'] = df['iloc'].map(dd.index.__getitem__) - df['prev'].map(dd.index.__getitem__)
    df['peak_dd'] = df.apply(lambda row: dd.iloc[row['prev']:row['iloc'] + 1].max(), axis=1)
    df = df.reindex(dd.index)
    return df['duration'], df['peak_dd']


def geometric_mean(returns: pd.Series) -> float:
    returns = returns.fillna(0) + 1
    if np.any(returns <= 0):
        return 0
    return np.exp(np.log(returns).sum() / (len(returns) or np.nan)) - 1


def compute_stats(
        trades: Union[List['Trade'], pd.DataFrame],
        equity: np.ndarray,
        ohlc_data: pd.DataFrame,
        strategy_instance: 'Strategy',
        risk_free_rate: float = 0,
) -> pd.Series:
    assert -1 < risk_free_rate < 1

    index = ohlc_data.index
    dd = 1 - equity / np.maximum.accumulate(equity)
    dd_dur, dd_peaks = compute_drawdown_duration_peaks(pd.Series(dd, index=index))

    equity_df = pd.DataFrame({
        'Equity': equity,
        'DrawdownPct': dd,
        'DrawdownDuration': dd_dur},
        index=index)

    if isinstance(trades, pd.DataFrame):
        trades_df: pd.DataFrame = trades
    else:
        # Came straight from Backtest.run()
        trades_df = pd.DataFrame({
            'Size': [t.size for t in trades],
            'EntryBar': [t.entry_bar for t in trades],
            'ExitBar': [t.exit_bar for t in trades],
            'EntryPrice': [t.entry_price for t in trades],
            'ExitPrice': [t.exit_price for t in trades],
            'PnL': [t.pl for t in trades],
            'ReturnPct': [t.pl_pct for t in trades],
            'EntryTime': [t.entry_time for t in trades],
            'ExitTime': [t.exit_time for t in trades],
            'Tag': [t.tag for t in trades],
        })
        trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime']
    del trades

    pl = trades_df['PnL']
    returns = trades_df['ReturnPct']
    durations = trades_df['Duration']

    def _round_timedelta(value, _period=_data_period(index)):
        if not isinstance(value, pd.Timedelta):
            return value
        resolution = getattr(_period, 'resolution_string', None) or _period.resolution
        return value.ceil(resolution)

    s = pd.Series(dtype=object)
    s.loc['Start'] = index[0]
    s.loc['End'] = index[-1]
    s.loc['Duration'] = s.End - s.Start

    have_position = np.repeat(0, len(index))
    for t in trades_df.itertuples(index=False):
        have_position[t.EntryBar:t.ExitBar + 1] = 1

    s.loc['Exposure Time [%]'] = have_position.mean() * 100  # In "n bars" time, not index time
    s.loc['Equity Final [$]'] = equity[-1]
    s.loc['Equity Peak [$]'] = equity.max()
    s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100
    c = ohlc_data.Close.values
    s.loc['Buy & Hold Return [%]'] = (c[-1] - c[0]) / c[0] * 100  # long-only return

    gmean_day_return: float = 0
    day_returns = np.array(np.nan)
    annual_trading_days = np.nan
    if isinstance(index, pd.DatetimeIndex):
        day_returns = equity_df['Equity'].resample('D').last().dropna().pct_change()
        gmean_day_return = geometric_mean(day_returns)
        annual_trading_days = float(
            365 if index.dayofweek.to_series().between(5, 6).mean() > 2/7 * .6 else
            252)

    # Annualized return and risk metrics are computed based on the (mostly correct)
    # assumption that the returns are compounded. See: https://dx.doi.org/10.2139/ssrn.3054517
    # Our annualized return matches `empyrical.annual_return(day_returns)` whereas
    # our risk doesn't; they use the simpler approach below.
    annualized_return = (1 + gmean_day_return)**annual_trading_days - 1
    s.loc['Return (Ann.) [%]'] = annualized_return * 100
    s.loc['Volatility (Ann.) [%]'] = np.sqrt((day_returns.var(ddof=int(bool(day_returns.shape))) + (1 + gmean_day_return)**2)**annual_trading_days - (1 + gmean_day_return)**(2*annual_trading_days)) * 100  # noqa: E501
    # s.loc['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100
    # s.loc['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100

    # Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return
    # and simple standard deviation
    s.loc['Sharpe Ratio'] = (s.loc['Return (Ann.) [%]'] - risk_free_rate) / (s.loc['Volatility (Ann.) [%]'] or np.nan)  # noqa: E501
    # Our Sortino mismatches `empyrical.sortino_ratio()` because they use arithmetic mean return
    s.loc['Sortino Ratio'] = (annualized_return - risk_free_rate) / (np.sqrt(np.mean(day_returns.clip(-np.inf, 0)**2)) * np.sqrt(annual_trading_days))  # noqa: E501
    max_dd = -np.nan_to_num(dd.max())
    s.loc['Calmar Ratio'] = annualized_return / (-max_dd or np.nan)
    s.loc['Max. Drawdown [%]'] = max_dd * 100
    s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100
    s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max())
    s.loc['Avg. Drawdown Duration'] = _round_timedelta(dd_dur.mean())
    s.loc['# Trades'] = n_trades = len(trades_df)
    win_rate = np.nan if not n_trades else (pl > 0).mean()
    s.loc['Win Rate [%]'] = win_rate * 100
    s.loc['Best Trade [%]'] = returns.max() * 100
    s.loc['Worst Trade [%]'] = returns.min() * 100
    mean_return = geometric_mean(returns)
    s.loc['Avg. Trade [%]'] = mean_return * 100
    s.loc['Max. Trade Duration'] = _round_timedelta(durations.max())
    s.loc['Avg. Trade Duration'] = _round_timedelta(durations.mean())
    s.loc['Profit Factor'] = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan)  # noqa: E501
    s.loc['Expectancy [%]'] = returns.mean() * 100
    s.loc['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan)
    s.loc['Kelly Criterion'] = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean())

    s.loc['_strategy'] = strategy_instance
    s.loc['_equity_curve'] = equity_df
    s.loc['_trades'] = trades_df

    s = _Stats(s)
    return s


class _Stats(pd.Series):
    def __repr__(self):
        # Prevent expansion due to _equity and _trades dfs
        with pd.option_context('max_colwidth', 20):
            return super().__repr__()
#region imports
from AlgorithmImports import *
#endregion
import warnings
from numbers import Number
from typing import Dict, List, Optional, Sequence, Union, cast

import numpy as np
import pandas as pd


def try_(lazy_func, default=None, exception=Exception):
    try:
        return lazy_func()
    except exception:
        return default


def _as_str(value) -> str:
    if isinstance(value, (Number, str)):
        return str(value)
    if isinstance(value, pd.DataFrame):
        return 'df'
    name = str(getattr(value, 'name', '') or '')
    if name in ('Open', 'High', 'Low', 'Close', 'Volume'):
        return name[:1]
    if callable(value):
        name = getattr(value, '__name__', value.__class__.__name__).replace('<lambda>', 'λ')
    if len(name) > 10:
        name = name[:9] + '…'
    return name


def _as_list(value) -> List:
    if isinstance(value, Sequence) and not isinstance(value, str):
        return list(value)
    return [value]


def _data_period(index) -> Union[pd.Timedelta, Number]:
    """Return data index period as pd.Timedelta"""
    values = pd.Series(index[-100:])
    return values.diff().dropna().median()


class _Array(np.ndarray):
    """
    ndarray extended to supply .name and other arbitrary properties
    in ._opts dict.
    """
    def __new__(cls, array, *, name=None, **kwargs):
        obj = np.asarray(array).view(cls)
        obj.name = name or array.name
        obj._opts = kwargs
        return obj

    def __array_finalize__(self, obj):
        if obj is not None:
            self.name = getattr(obj, 'name', '')
            self._opts = getattr(obj, '_opts', {})

    # Make sure properties name and _opts are carried over
    # when (un-)pickling.
    def __reduce__(self):
        value = super().__reduce__()
        return value[:2] + (value[2] + (self.__dict__,),)

    def __setstate__(self, state):
        self.__dict__.update(state[-1])
        super().__setstate__(state[:-1])

    def __bool__(self):
        try:
            return bool(self[-1])
        except IndexError:
            return super().__bool__()

    def __float__(self):
        try:
            return float(self[-1])
        except IndexError:
            return super().__float__()

    def to_series(self):
        warnings.warn("`.to_series()` is deprecated. For pd.Series conversion, use accessor `.s`")
        return self.s

    @property
    def s(self) -> pd.Series:
        values = np.atleast_2d(self)
        index = self._opts['index'][:values.shape[1]]
        return pd.Series(values[0], index=index, name=self.name)

    @property
    def df(self) -> pd.DataFrame:
        values = np.atleast_2d(np.asarray(self))
        index = self._opts['index'][:values.shape[1]]
        df = pd.DataFrame(values.T, index=index, columns=[self.name] * len(values))
        return df


class _Indicator(_Array):
    pass


class _Data:
    """
    A data array accessor. Provides access to OHLCV "columns"
    as a standard `pd.DataFrame` would, except it's not a DataFrame
    and the returned "series" are _not_ `pd.Series` but `np.ndarray`
    for performance reasons.
    """
    def __init__(self, df: pd.DataFrame):
        self.__df = df
        self.__i = len(df)
        self.__pip: Optional[float] = None
        self.__cache: Dict[str, _Array] = {}
        self.__arrays: Dict[str, _Array] = {}
        self._update()

    def __getitem__(self, item):
        return self.__get_array(item)

    def __getattr__(self, item):
        try:
            return self.__get_array(item)
        except KeyError:
            raise AttributeError(f"Column '{item}' not in data") from None

    def _set_length(self, i):
        self.__i = i
        self.__cache.clear()

    def _update(self):
        index = self.__df.index.copy()
        self.__arrays = {col: _Array(arr, index=index)
                         for col, arr in self.__df.items()}
        # Leave index as Series because pd.Timestamp nicer API to work with
        self.__arrays['__index'] = index

    def __repr__(self):
        i = min(self.__i, len(self.__df)) - 1
        index = self.__arrays['__index'][i]
        items = ', '.join(f'{k}={v}' for k, v in self.__df.iloc[i].items())
        return f'<Data i={i} ({index}) {items}>'

    def __len__(self):
        return self.__i

    @property
    def df(self) -> pd.DataFrame:
        return (self.__df.iloc[:self.__i]
                if self.__i < len(self.__df)
                else self.__df)

    @property
    def pip(self) -> float:
        if self.__pip is None:
            self.__pip = float(10**-np.median([len(s.partition('.')[-1])
                                               for s in self.__arrays['Close'].astype(str)]))
        return self.__pip

    def __get_array(self, key) -> _Array:
        arr = self.__cache.get(key)
        if arr is None:
            arr = self.__cache[key] = cast(_Array, self.__arrays[key][:self.__i])
        return arr

    @property
    def Open(self) -> _Array:
        return self.__get_array('Open')

    @property
    def High(self) -> _Array:
        return self.__get_array('High')

    @property
    def Low(self) -> _Array:
        return self.__get_array('Low')

    @property
    def Close(self) -> _Array:
        return self.__get_array('Close')

    @property
    def Volume(self) -> _Array:
        return self.__get_array('Volume')

    @property
    def index(self) -> pd.DatetimeIndex:
        return self.__get_array('__index')

    # Make pickling in Backtest.optimize() work with our catch-all __getattr__
    def __getstate__(self):
        return self.__dict__

    def __setstate__(self, state):
        self.__dict__ = state
#region imports
from AlgorithmImports import *
#endregion
"""
Core framework data structures.
Objects from this module can also be imported from the top-level
module directly, e.g.

    from backtesting import Backtest, Strategy
"""
import multiprocessing as mp
import os
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from functools import lru_cache, partial
from itertools import compress, product, repeat
from math import copysign
from numbers import Number
from typing import Callable, Dict, List, Optional, Sequence, Tuple, Type, Union

import numpy as np
import pandas as pd
from numpy.random import default_rng

try:
    from tqdm.auto import tqdm as _tqdm
    _tqdm = partial(_tqdm, leave=False)
except ImportError:
    def _tqdm(seq, **_):
        return seq

from ._plotting import plot  # noqa: I001
from ._stats import compute_stats
from ._util import _Indicator, _Data, try_
from .strategy import Strategy
from .position import Position
from .order import Order
from .trade import Trade

class _OutOfMoneyError(Exception):
    pass

class _Broker:
    def __init__(self, *, data, cash, commission, margin,
                 trade_on_close, hedging, exclusive_orders, index):
        assert 0 < cash, f"cash should be >0, is {cash}"
        assert -.1 <= commission < .1, \
            ("commission should be between -10% "
             f"(e.g. market-maker's rebates) and 10% (fees), is {commission}")
        assert 0 < margin <= 1, f"margin should be between 0 and 1, is {margin}"
        self._data: _Data = data
        self._cash = cash
        self._commission = commission
        self._leverage = 1 / margin
        self._trade_on_close = trade_on_close
        self._hedging = hedging
        self._exclusive_orders = exclusive_orders

        self._equity = np.tile(np.nan, len(index))
        self.orders: List[Order] = []
        self.trades: List[Trade] = []
        self.position = Position(self)
        self.closed_trades: List[Trade] = []

    def __repr__(self):
        return f'<Broker: {self._cash:.0f}{self.position.pl:+.1f} ({len(self.trades)} trades)>'

    def new_order(self,
                  size: float,
                  limit: Optional[float] = None,
                  stop: Optional[float] = None,
                  sl: Optional[float] = None,
                  tp: Optional[float] = None,
                  tag: object = None,
                  *,
                  trade: Optional[Trade] = None):
        """
        Argument size indicates whether the order is long or short
        """
        size = float(size)
        stop = stop and float(stop)
        limit = limit and float(limit)
        sl = sl and float(sl)
        tp = tp and float(tp)

        is_long = size > 0
        adjusted_price = self._adjusted_price(size)

        if is_long:
            if not (sl or -np.inf) < (limit or stop or adjusted_price) < (tp or np.inf):
                raise ValueError(
                    "Long orders require: "
                    f"SL ({sl}) < LIMIT ({limit or stop or adjusted_price}) < TP ({tp})")
        else:
            if not (tp or -np.inf) < (limit or stop or adjusted_price) < (sl or np.inf):
                raise ValueError(
                    "Short orders require: "
                    f"TP ({tp}) < LIMIT ({limit or stop or adjusted_price}) < SL ({sl})")

        order = Order(self, size, limit, stop, sl, tp, trade, tag)
        # Put the new order in the order queue,
        # inserting SL/TP/trade-closing orders in-front
        if trade:
            self.orders.insert(0, order)
        else:
            # If exclusive orders (each new order auto-closes previous orders/position),
            # cancel all non-contingent orders and close all open trades beforehand
            if self._exclusive_orders:
                for o in self.orders:
                    if not o.is_contingent:
                        o.cancel()
                for t in self.trades:
                    t.close()

            self.orders.append(order)

        return order

    @property
    def last_price(self) -> float:
        """ Price at the last (current) close. """
        return self._data.Close[-1]

    def _adjusted_price(self, size=None, price=None) -> float:
        """
        Long/short `price`, adjusted for commisions.
        In long positions, the adjusted price is a fraction higher, and vice versa.
        """
        return (price or self.last_price) * (1 + copysign(self._commission, size))

    @property
    def equity(self) -> float:
        return self._cash + sum(trade.pl for trade in self.trades)

    @property
    def margin_available(self) -> float:
        # From https://github.com/QuantConnect/Lean/pull/3768
        margin_used = sum(trade.value / self._leverage for trade in self.trades)
        return max(0, self.equity - margin_used)

    def next(self):
        i = self._i = len(self._data) - 1
        self._process_orders()

        # Log account equity for the equity curve
        equity = self.equity
        self._equity[i] = equity

        # If equity is negative, set all to 0 and stop the simulation
        if equity <= 0:
            assert self.margin_available <= 0
            for trade in self.trades:
                self._close_trade(trade, self._data.Close[-1], i)
            self._cash = 0
            self._equity[i:] = 0
            raise _OutOfMoneyError

    def _process_orders(self):
        data = self._data
        open, high, low = data.Open[-1], data.High[-1], data.Low[-1]
        prev_close = data.Close[-2]
        reprocess_orders = False

        # Process orders
        for order in list(self.orders):  # type: Order

            # Related SL/TP order was already removed
            if order not in self.orders:
                continue

            # Check if stop condition was hit
            stop_price = order.stop
            if stop_price:
                is_stop_hit = ((high > stop_price) if order.is_long else (low < stop_price))
                if not is_stop_hit:
                    continue

                # > When the stop price is reached, a stop order becomes a market/limit order.
                # https://www.sec.gov/fast-answers/answersstopordhtm.html
                order._replace(stop_price=None)

            # Determine purchase price.
            # Check if limit order can be filled.
            if order.limit:
                is_limit_hit = low < order.limit if order.is_long else high > order.limit
                # When stop and limit are hit within the same bar, we pessimistically
                # assume limit was hit before the stop (i.e. "before it counts")
                is_limit_hit_before_stop = (is_limit_hit and
                                            (order.limit < (stop_price or -np.inf)
                                             if order.is_long
                                             else order.limit > (stop_price or np.inf)))
                if not is_limit_hit or is_limit_hit_before_stop:
                    continue

                # stop_price, if set, was hit within this bar
                price = (min(stop_price or open, order.limit)
                         if order.is_long else
                         max(stop_price or open, order.limit))
            else:
                # Market-if-touched / market order
                price = prev_close if self._trade_on_close else open
                price = (max(price, stop_price or -np.inf)
                         if order.is_long else
                         min(price, stop_price or np.inf))

            # Determine entry/exit bar index
            is_market_order = not order.limit and not stop_price
            time_index = (self._i - 1) if is_market_order and self._trade_on_close else self._i

            # If order is a SL/TP order, it should close an existing trade it was contingent upon
            if order.parent_trade:
                trade = order.parent_trade
                _prev_size = trade.size
                # If order.size is "greater" than trade.size, this order is a trade.close()
                # order and part of the trade was already closed beforehand
                size = copysign(min(abs(_prev_size), abs(order.size)), order.size)
                # If this trade isn't already closed (e.g. on multiple `trade.close(.5)` calls)
                if trade in self.trades:
                    self._reduce_trade(trade, price, size, time_index)
                    assert order.size != -_prev_size or trade not in self.trades
                if order in (trade._sl_order,
                             trade._tp_order):
                    assert order.size == -trade.size
                    assert order not in self.orders  # Removed when trade was closed
                else:
                    # It's a trade.close() order, now done
                    assert abs(_prev_size) >= abs(size) >= 1
                    self.orders.remove(order)
                continue

            # Else this is a stand-alone trade

            # Adjust price to include commission (or bid-ask spread).
            # In long positions, the adjusted price is a fraction higher, and vice versa.
            adjusted_price = self._adjusted_price(order.size, price)

            # If order size was specified proportionally,
            # precompute true size in units, accounting for margin and spread/commissions
            size = order.size
            if -1 < size < 1:
                size = copysign(int((self.margin_available * self._leverage * abs(size))
                                    // adjusted_price), size)
                # Not enough cash/margin even for a single unit
                if not size:
                    self.orders.remove(order)
                    continue
            assert size == round(size)
            need_size = int(size)

            if not self._hedging:
                # Fill position by FIFO closing/reducing existing opposite-facing trades.
                # Existing trades are closed at unadjusted price, because the adjustment
                # was already made when buying.
                for trade in list(self.trades):
                    if trade.is_long == order.is_long:
                        continue
                    assert trade.size * order.size < 0

                    # Order size greater than this opposite-directed existing trade,
                    # so it will be closed completely
                    if abs(need_size) >= abs(trade.size):
                        self._close_trade(trade, price, time_index)
                        need_size += trade.size
                    else:
                        # The existing trade is larger than the new order,
                        # so it will only be closed partially
                        self._reduce_trade(trade, price, need_size, time_index)
                        need_size = 0

                    if not need_size:
                        break

            # If we don't have enough liquidity to cover for the order, cancel it
            if abs(need_size) * adjusted_price > self.margin_available * self._leverage:
                self.orders.remove(order)
                continue

            # Open a new trade
            if need_size:
                self._open_trade(adjusted_price,
                                 need_size,
                                 order.sl,
                                 order.tp,
                                 time_index,
                                 order.tag)

                # We need to reprocess the SL/TP orders newly added to the queue.
                # This allows e.g. SL hitting in the same bar the order was open.
                # See https://github.com/kernc/backtesting.py/issues/119
                if order.sl or order.tp:
                    if is_market_order:
                        reprocess_orders = True
                    elif (low <= (order.sl or -np.inf) <= high or
                          low <= (order.tp or -np.inf) <= high):
                        warnings.warn(
                            f"({data.index[-1]}) A contingent SL/TP order would execute in the "
                            "same bar its parent stop/limit order was turned into a trade. "
                            "Since we can't assert the precise intra-candle "
                            "price movement, the affected SL/TP order will instead be executed on "
                            "the next (matching) price/bar, making the result (of this trade) "
                            "somewhat dubious. "
                            "See https://github.com/kernc/backtesting.py/issues/119",
                            UserWarning)

            # Order processed
            self.orders.remove(order)

        if reprocess_orders:
            self._process_orders()

    def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int):
        assert trade.size * size < 0
        assert abs(trade.size) >= abs(size)

        size_left = trade.size + size
        assert size_left * trade.size >= 0
        if not size_left:
            close_trade = trade
        else:
            # Reduce existing trade ...
            trade._replace(size=size_left)
            if trade._sl_order:
                trade._sl_order._replace(size=-trade.size)
            if trade._tp_order:
                trade._tp_order._replace(size=-trade.size)

            # ... by closing a reduced copy of it
            close_trade = trade._copy(size=-size, sl_order=None, tp_order=None)
            self.trades.append(close_trade)

        self._close_trade(close_trade, price, time_index)

    def _close_trade(self, trade: Trade, price: float, time_index: int):
        self.trades.remove(trade)
        if trade._sl_order:
            self.orders.remove(trade._sl_order)
        if trade._tp_order:
            self.orders.remove(trade._tp_order)

        self.closed_trades.append(trade._replace(exit_price=price, exit_bar=time_index))
        self._cash += trade.pl

    def _open_trade(self, price: float, size: int,
                    sl: Optional[float], tp: Optional[float], time_index: int, tag):
        trade = Trade(self, size, price, time_index, tag)
        self.trades.append(trade)
        # Create SL/TP (bracket) orders.
        # Make sure SL order is created first so it gets adversarially processed before TP order
        # in case of an ambiguous tie (both hit within a single bar).
        # Note, sl/tp orders are inserted at the front of the list, thus order reversed.
        if tp:
            trade.tp = tp
        if sl:
            trade.sl = sl

class Backtest:
    """
    Backtest a particular (parameterized) strategy
    on particular data.

    Upon initialization, call method
    `backtesting.backtesting.Backtest.run` to run a backtest
    instance, or `backtesting.backtesting.Backtest.optimize` to
    optimize it.
    """
    def __init__(self,
                 data: pd.DataFrame,
                 strategy: Type[Strategy],
                 *,
                 cash: float = 10_000,
                 commission: float = .0,
                 margin: float = 1.,
                 trade_on_close=False,
                 hedging=False,
                 exclusive_orders=False
                 ):
        """
        Initialize a backtest. Requires data and a strategy to test.

        `data` is a `pd.DataFrame` with columns:
        `Open`, `High`, `Low`, `Close`, and (optionally) `Volume`.
        If any columns are missing, set them to what you have available,
        e.g.

            df['Open'] = df['High'] = df['Low'] = df['Close']

        The passed data frame can contain additional columns that
        can be used by the strategy (e.g. sentiment info).
        DataFrame index can be either a datetime index (timestamps)
        or a monotonic range index (i.e. a sequence of periods).

        `strategy` is a `backtesting.backtesting.Strategy`
        _subclass_ (not an instance).

        `cash` is the initial cash to start with.

        `commission` is the commission ratio. E.g. if your broker's commission
        is 1% of trade value, set commission to `0.01`. Note, if you wish to
        account for bid-ask spread, you can approximate doing so by increasing
        the commission, e.g. set it to `0.0002` for commission-less forex
        trading where the average spread is roughly 0.2‰ of asking price.

        `margin` is the required margin (ratio) of a leveraged account.
        No difference is made between initial and maintenance margins.
        To run the backtest using e.g. 50:1 leverge that your broker allows,
        set margin to `0.02` (1 / leverage).

        If `trade_on_close` is `True`, market orders will be filled
        with respect to the current bar's closing price instead of the
        next bar's open.

        If `hedging` is `True`, allow trades in both directions simultaneously.
        If `False`, the opposite-facing orders first close existing trades in
        a [FIFO] manner.

        If `exclusive_orders` is `True`, each new order auto-closes the previous
        trade/position, making at most a single trade (long or short) in effect
        at each time.

        [FIFO]: https://www.investopedia.com/terms/n/nfa-compliance-rule-2-43b.asp
        """

        if not (isinstance(strategy, type) and issubclass(strategy, Strategy)):
            raise TypeError('`strategy` must be a Strategy sub-type')
        if not isinstance(data, pd.DataFrame):
            raise TypeError("`data` must be a pandas.DataFrame with columns")
        if not isinstance(commission, Number):
            raise TypeError('`commission` must be a float value, percent of '
                            'entry order price')

        data = data.copy(deep=False)

        # Convert index to datetime index
        if (not isinstance(data.index, pd.DatetimeIndex) and
            not isinstance(data.index, pd.RangeIndex) and
            # Numeric index with most large numbers
            (data.index.is_numeric() and
             (data.index > pd.Timestamp('1975').timestamp()).mean() > .8)):
            try:
                data.index = pd.to_datetime(data.index, infer_datetime_format=True)
            except ValueError:
                pass

        if 'Volume' not in data:
            data['Volume'] = np.nan

        if len(data) == 0:
            raise ValueError('OHLC `data` is empty')
        if len(data.columns.intersection({'Open', 'High', 'Low', 'Close', 'Volume'})) != 5:
            raise ValueError("`data` must be a pandas.DataFrame with columns "
                             "'Open', 'High', 'Low', 'Close', and (optionally) 'Volume'")
        if data[['Open', 'High', 'Low', 'Close']].isnull().values.any():
            raise ValueError('Some OHLC values are missing (NaN). '
                             'Please strip those lines with `df.dropna()` or '
                             'fill them in with `df.interpolate()` or whatever.')
        if np.any(data['Close'] > cash):
            warnings.warn('Some prices are larger than initial cash value. Note that fractional '
                          'trading is not supported. If you want to trade Bitcoin, '
                          'increase initial cash, or trade μBTC or satoshis instead (GH-134).',
                          stacklevel=2)
        if not data.index.is_monotonic_increasing:
            warnings.warn('Data index is not sorted in ascending order. Sorting.',
                          stacklevel=2)
            data = data.sort_index()
        if not isinstance(data.index, pd.DatetimeIndex):
            warnings.warn('Data index is not datetime. Assuming simple periods, '
                          'but `pd.DateTimeIndex` is advised.',
                          stacklevel=2)

        self._data: pd.DataFrame = data
        self._broker = partial(
            _Broker, cash=cash, commission=commission, margin=margin,
            trade_on_close=trade_on_close, hedging=hedging,
            exclusive_orders=exclusive_orders, index=data.index,
        )
        self._strategy = strategy
        self._results: Optional[pd.Series] = None

    def run(self, **kwargs) -> pd.Series:
        """
        Run the backtest. Returns `pd.Series` with results and statistics.

        Keyword arguments are interpreted as strategy parameters.

            >>> Backtest(GOOG, SmaCross).run()
            Start                     2004-08-19 00:00:00
            End                       2013-03-01 00:00:00
            Duration                   3116 days 00:00:00
            Exposure Time [%]                     93.9944
            Equity Final [$]                      51959.9
            Equity Peak [$]                       75787.4
            Return [%]                            419.599
            Buy & Hold Return [%]                 703.458
            Return (Ann.) [%]                      21.328
            Volatility (Ann.) [%]                 36.5383
            Sharpe Ratio                         0.583718
            Sortino Ratio                         1.09239
            Calmar Ratio                         0.444518
            Max. Drawdown [%]                    -47.9801
            Avg. Drawdown [%]                    -5.92585
            Max. Drawdown Duration      584 days 00:00:00
            Avg. Drawdown Duration       41 days 00:00:00
            # Trades                                   65
            Win Rate [%]                          46.1538
            Best Trade [%]                         53.596
            Worst Trade [%]                      -18.3989
            Avg. Trade [%]                        2.35371
            Max. Trade Duration         183 days 00:00:00
            Avg. Trade Duration          46 days 00:00:00
            Profit Factor                         2.08802
            Expectancy [%]                        8.79171
            SQN                                  0.916893
            Kelly Criterion                        0.6134
            _strategy                            SmaCross
            _equity_curve                           Eq...
            _trades                       Size  EntryB...
            dtype: object

        .. warning::
            You may obtain different results for different strategy parameters.
            E.g. if you use 50- and 200-bar SMA, the trading simulation will
            begin on bar 201. The actual length of delay is equal to the lookback
            period of the `Strategy.I` indicator which lags the most.
            Obviously, this can affect results.
        """
        data = _Data(self._data.copy(deep=False))
        broker: _Broker = self._broker(data=data)
        strategy: Strategy = self._strategy(broker, data, kwargs)

        strategy.init()
        data._update()  # Strategy.init might have changed/added to data.df

        # Indicators used in Strategy.next()
        indicator_attrs = {attr: indicator
                           for attr, indicator in strategy.__dict__.items()
                           if isinstance(indicator, _Indicator)}.items()

        # Skip first few candles where indicators are still "warming up"
        # +1 to have at least two entries available
        start = 1 + max((np.isnan(indicator.astype(float)).argmin(axis=-1).max()
                         for _, indicator in indicator_attrs), default=0)

        # Disable "invalid value encountered in ..." warnings. Comparison
        # np.nan >= 3 is not invalid; it's False.
        with np.errstate(invalid='ignore'):

            for i in range(start, len(self._data)):
                # Prepare data and indicators for `next` call
                data._set_length(i + 1)
                for attr, indicator in indicator_attrs:
                    # Slice indicator on the last dimension (case of 2d indicator)
                    setattr(strategy, attr, indicator[..., :i + 1])

                # Handle orders processing and broker stuff
                try:
                    broker.next()
                except _OutOfMoneyError:
                    break

                # Next tick, a moment before bar close
                strategy.next()
            else:
                # Close any remaining open trades so they produce some stats
                for trade in broker.trades:
                    trade.close()

                # Re-run broker one last time to handle orders placed in the last strategy
                # iteration. Use the same OHLC values as in the last broker iteration.
                if start < len(self._data):
                    try_(broker.next, exception=_OutOfMoneyError)

            # Set data back to full length
            # for future `indicator._opts['data'].index` calls to work
            data._set_length(len(self._data))

            equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values
            self._results = compute_stats(
                trades=broker.closed_trades,
                equity=equity,
                ohlc_data=self._data,
                risk_free_rate=0.0,
                strategy_instance=strategy,
            )

        return self._results

    def optimize(self, *,
                 maximize: Union[str, Callable[[pd.Series], float]] = 'SQN',
                 method: str = 'grid',
                 max_tries: Optional[Union[int, float]] = None,
                 constraint: Optional[Callable[[dict], bool]] = None,
                 return_heatmap: bool = False,
                 return_optimization: bool = False,
                 random_state: Optional[int] = None,
                 **kwargs) -> Union[pd.Series,
                                    Tuple[pd.Series, pd.Series],
                                    Tuple[pd.Series, pd.Series, dict]]:
        """
        Optimize strategy parameters to an optimal combination.
        Returns result `pd.Series` of the best run.

        `maximize` is a string key from the
        `backtesting.backtesting.Backtest.run`-returned results series,
        or a function that accepts this series object and returns a number;
        the higher the better. By default, the method maximizes
        Van Tharp's [System Quality Number](https://google.com/search?q=System+Quality+Number).

        `method` is the optimization method. Currently two methods are supported:

        * `"grid"` which does an exhaustive (or randomized) search over the
          cartesian product of parameter combinations, and
        * `"skopt"` which finds close-to-optimal strategy parameters using
          [model-based optimization], making at most `max_tries` evaluations.

        [model-based optimization]: \
            https://scikit-optimize.github.io/stable/auto_examples/bayesian-optimization.html

        `max_tries` is the maximal number of strategy runs to perform.
        If `method="grid"`, this results in randomized grid search.
        If `max_tries` is a floating value between (0, 1], this sets the
        number of runs to approximately that fraction of full grid space.
        Alternatively, if integer, it denotes the absolute maximum number
        of evaluations. If unspecified (default), grid search is exhaustive,
        whereas for `method="skopt"`, `max_tries` is set to 200.

        `constraint` is a function that accepts a dict-like object of
        parameters (with values) and returns `True` when the combination
        is admissible to test with. By default, any parameters combination
        is considered admissible.

        If `return_heatmap` is `True`, besides returning the result
        series, an additional `pd.Series` is returned with a multiindex
        of all admissible parameter combinations, which can be further
        inspected or projected onto 2D to plot a heatmap
        (see `backtesting.lib.plot_heatmaps()`).

        If `return_optimization` is True and `method = 'skopt'`,
        in addition to result series (and maybe heatmap), return raw
        [`scipy.optimize.OptimizeResult`][OptimizeResult] for further
        inspection, e.g. with [scikit-optimize]\
        [plotting tools].

        [OptimizeResult]: \
            https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.OptimizeResult.html
        [scikit-optimize]: https://scikit-optimize.github.io
        [plotting tools]: https://scikit-optimize.github.io/stable/modules/plots.html

        If you want reproducible optimization results, set `random_state`
        to a fixed integer random seed.

        Additional keyword arguments represent strategy arguments with
        list-like collections of possible values. For example, the following
        code finds and returns the "best" of the 7 admissible (of the
        9 possible) parameter combinations:

            backtest.optimize(sma1=[5, 10, 15], sma2=[10, 20, 40],
                              constraint=lambda p: p.sma1 < p.sma2)

        .. TODO::
            Improve multiprocessing/parallel execution on Windos with start method 'spawn'.
        """
        if not kwargs:
            raise ValueError('Need some strategy parameters to optimize')

        maximize_key = None
        if isinstance(maximize, str):
            maximize_key = str(maximize)
            stats = self._results if self._results is not None else self.run()
            if maximize not in stats:
                raise ValueError('`maximize`, if str, must match a key in pd.Series '
                                 'result of backtest.run()')

            def maximize(stats: pd.Series, _key=maximize):
                return stats[_key]

        elif not callable(maximize):
            raise TypeError('`maximize` must be str (a field of backtest.run() result '
                            'Series) or a function that accepts result Series '
                            'and returns a number; the higher the better')
        assert callable(maximize), maximize

        have_constraint = bool(constraint)
        if constraint is None:

            def constraint(_):
                return True

        elif not callable(constraint):
            raise TypeError("`constraint` must be a function that accepts a dict "
                            "of strategy parameters and returns a bool whether "
                            "the combination of parameters is admissible or not")
        assert callable(constraint), constraint

        if return_optimization and method != 'skopt':
            raise ValueError("return_optimization=True only valid if method='skopt'")

        def _tuple(x):
            return x if isinstance(x, Sequence) and not isinstance(x, str) else (x,)

        for k, v in kwargs.items():
            if len(_tuple(v)) == 0:
                raise ValueError(f"Optimization variable '{k}' is passed no "
                                 f"optimization values: {k}={v}")

        class AttrDict(dict):
            def __getattr__(self, item):
                return self[item]

        def _grid_size():
            size = int(np.prod([len(_tuple(v)) for v in kwargs.values()]))
            if size < 10_000 and have_constraint:
                size = sum(1 for p in product(*(zip(repeat(k), _tuple(v))
                                                for k, v in kwargs.items()))
                           if constraint(AttrDict(p)))
            return size

        def _optimize_grid() -> Union[pd.Series, Tuple[pd.Series, pd.Series]]:
            rand = default_rng(random_state).random
            grid_frac = (1 if max_tries is None else
                         max_tries if 0 < max_tries <= 1 else
                         max_tries / _grid_size())
            param_combos = [dict(params)  # back to dict so it pickles
                            for params in (AttrDict(params)
                                           for params in product(*(zip(repeat(k), _tuple(v))
                                                                   for k, v in kwargs.items())))
                            if constraint(params)  # type: ignore
                            and rand() <= grid_frac]
            if not param_combos:
                raise ValueError('No admissible parameter combinations to test')

            if len(param_combos) > 300:
                warnings.warn(f'Searching for best of {len(param_combos)} configurations.',
                              stacklevel=2)

            heatmap = pd.Series(np.nan,
                                name=maximize_key,
                                index=pd.MultiIndex.from_tuples(
                                    [p.values() for p in param_combos],
                                    names=next(iter(param_combos)).keys()))

            def _batch(seq):
                n = np.clip(int(len(seq) // (os.cpu_count() or 1)), 1, 300)
                for i in range(0, len(seq), n):
                    yield seq[i:i + n]

            # Save necessary objects into "global" state; pass into concurrent executor
            # (and thus pickle) nothing but two numbers; receive nothing but numbers.
            # With start method "fork", children processes will inherit parent address space
            # in a copy-on-write manner, achieving better performance/RAM benefit.
            backtest_uuid = np.random.random()
            param_batches = list(_batch(param_combos))
            Backtest._mp_backtests[backtest_uuid] = (self, param_batches, maximize)  # type: ignore
            try:
                # If multiprocessing start method is 'fork' (i.e. on POSIX), use
                # a pool of processes to compute results in parallel.
                # Otherwise (i.e. on Windos), sequential computation will be "faster".
                if mp.get_start_method(allow_none=False) == 'fork':
                    with ProcessPoolExecutor() as executor:
                        futures = [executor.submit(Backtest._mp_task, backtest_uuid, i)
                                   for i in range(len(param_batches))]
                        for future in _tqdm(as_completed(futures), total=len(futures),
                                            desc='Backtest.optimize'):
                            batch_index, values = future.result()
                            for value, params in zip(values, param_batches[batch_index]):
                                heatmap[tuple(params.values())] = value
                else:
                    if os.name == 'posix':
                        warnings.warn("For multiprocessing support in `Backtest.optimize()` "
                                      "set multiprocessing start method to 'fork'.")
                    for batch_index in _tqdm(range(len(param_batches))):
                        _, values = Backtest._mp_task(backtest_uuid, batch_index)
                        for value, params in zip(values, param_batches[batch_index]):
                            heatmap[tuple(params.values())] = value
            finally:
                del Backtest._mp_backtests[backtest_uuid]

            best_params = heatmap.idxmax()

            if pd.isnull(best_params):
                # No trade was made in any of the runs. Just make a random
                # run so we get some, if empty, results
                stats = self.run(**param_combos[0])
            else:
                stats = self.run(**dict(zip(heatmap.index.names, best_params)))

            if return_heatmap:
                return stats, heatmap
            return stats

        def _optimize_skopt() -> Union[pd.Series,
                                       Tuple[pd.Series, pd.Series],
                                       Tuple[pd.Series, pd.Series, dict]]:
            try:
                from skopt import forest_minimize
                from skopt.callbacks import DeltaXStopper
                from skopt.learning import ExtraTreesRegressor
                from skopt.space import Categorical, Integer, Real
                from skopt.utils import use_named_args
            except ImportError:
                raise ImportError("Need package 'scikit-optimize' for method='skopt'. "
                                  "pip install scikit-optimize") from None

            nonlocal max_tries
            max_tries = (200 if max_tries is None else
                         max(1, int(max_tries * _grid_size())) if 0 < max_tries <= 1 else
                         max_tries)

            dimensions = []
            for key, values in kwargs.items():
                values = np.asarray(values)
                if values.dtype.kind in 'mM':  # timedelta, datetime64
                    # these dtypes are unsupported in skopt, so convert to raw int
                    # TODO: save dtype and convert back later
                    values = values.astype(int)

                if values.dtype.kind in 'iumM':
                    dimensions.append(Integer(low=values.min(), high=values.max(), name=key))
                elif values.dtype.kind == 'f':
                    dimensions.append(Real(low=values.min(), high=values.max(), name=key))
                else:
                    dimensions.append(Categorical(values.tolist(), name=key, transform='onehot'))

            # Avoid recomputing re-evaluations:
            # "The objective has been evaluated at this point before."
            # https://github.com/scikit-optimize/scikit-optimize/issues/302
            memoized_run = lru_cache()(lambda tup: self.run(**dict(tup)))

            # np.inf/np.nan breaks sklearn, np.finfo(float).max breaks skopt.plots.plot_objective
            INVALID = 1e300
            progress = iter(_tqdm(repeat(None), total=max_tries, desc='Backtest.optimize'))

            @use_named_args(dimensions=dimensions)
            def objective_function(**params):
                next(progress)
                # Check constraints
                # TODO: Adjust after https://github.com/scikit-optimize/scikit-optimize/pull/971
                if not constraint(AttrDict(params)):
                    return INVALID
                res = memoized_run(tuple(params.items()))
                value = -maximize(res)
                if np.isnan(value):
                    return INVALID
                return value

            with warnings.catch_warnings():
                warnings.filterwarnings(
                    'ignore', 'The objective has been evaluated at this point before.')

                res = forest_minimize(
                    func=objective_function,
                    dimensions=dimensions,
                    n_calls=max_tries,
                    base_estimator=ExtraTreesRegressor(n_estimators=20, min_samples_leaf=2),
                    acq_func='LCB',
                    kappa=3,
                    n_initial_points=min(max_tries, 20 + 3 * len(kwargs)),
                    initial_point_generator='lhs',  # 'sobel' requires n_initial_points ~ 2**N
                    callback=DeltaXStopper(9e-7),
                    random_state=random_state)

            stats = self.run(**dict(zip(kwargs.keys(), res.x)))
            output = [stats]

            if return_heatmap:
                heatmap = pd.Series(dict(zip(map(tuple, res.x_iters), -res.func_vals)),
                                    name=maximize_key)
                heatmap.index.names = kwargs.keys()
                heatmap = heatmap[heatmap != -INVALID]
                heatmap.sort_index(inplace=True)
                output.append(heatmap)

            if return_optimization:
                valid = res.func_vals != INVALID
                res.x_iters = list(compress(res.x_iters, valid))
                res.func_vals = res.func_vals[valid]
                output.append(res)

            return stats if len(output) == 1 else tuple(output)

        if method == 'grid':
            output = _optimize_grid()
        elif method == 'skopt':
            output = _optimize_skopt()
        else:
            raise ValueError(f"Method should be 'grid' or 'skopt', not {method!r}")
        return output

    @staticmethod
    def _mp_task(backtest_uuid, batch_index):
        bt, param_batches, maximize_func = Backtest._mp_backtests[backtest_uuid]
        return batch_index, [maximize_func(stats) if stats['# Trades'] else np.nan
                             for stats in (bt.run(**params)
                                           for params in param_batches[batch_index])]

    _mp_backtests: Dict[float, Tuple['Backtest', List, Callable]] = {}

    def plot(self, *, results: pd.Series = None, filename=None, plot_width=None,
             plot_equity=True, plot_return=False, plot_pl=True,
             plot_volume=True, plot_drawdown=False, plot_trades=True,
             smooth_equity=False, relative_equity=True,
             superimpose: Union[bool, str] = True,
             resample=True, reverse_indicators=False,
             show_legend=True, open_browser=True):
        """
        Plot the progression of the last backtest run.

        If `results` is provided, it should be a particular result
        `pd.Series` such as returned by
        `backtesting.backtesting.Backtest.run` or
        `backtesting.backtesting.Backtest.optimize`, otherwise the last
        run's results are used.

        `filename` is the path to save the interactive HTML plot to.
        By default, a strategy/parameter-dependent file is created in the
        current working directory.

        `plot_width` is the width of the plot in pixels. If None (default),
        the plot is made to span 100% of browser width. The height is
        currently non-adjustable.

        If `plot_equity` is `True`, the resulting plot will contain
        an equity (initial cash plus assets) graph section. This is the same
        as `plot_return` plus initial 100%.

        If `plot_return` is `True`, the resulting plot will contain
        a cumulative return graph section. This is the same
        as `plot_equity` minus initial 100%.

        If `plot_pl` is `True`, the resulting plot will contain
        a profit/loss (P/L) indicator section.

        If `plot_volume` is `True`, the resulting plot will contain
        a trade volume section.

        If `plot_drawdown` is `True`, the resulting plot will contain
        a separate drawdown graph section.

        If `plot_trades` is `True`, the stretches between trade entries
        and trade exits are marked by hash-marked tractor beams.

        If `smooth_equity` is `True`, the equity graph will be
        interpolated between fixed points at trade closing times,
        unaffected by any interim asset volatility.

        If `relative_equity` is `True`, scale and label equity graph axis
        with return percent, not absolute cash-equivalent values.

        If `superimpose` is `True`, superimpose larger-timeframe candlesticks
        over the original candlestick chart. Default downsampling rule is:
        monthly for daily data, daily for hourly data, hourly for minute data,
        and minute for (sub-)second data.
        `superimpose` can also be a valid [Pandas offset string],
        such as `'5T'` or `'5min'`, in which case this frequency will be
        used to superimpose.
        Note, this only works for data with a datetime index.

        If `resample` is `True`, the OHLC data is resampled in a way that
        makes the upper number of candles for Bokeh to plot limited to 10_000.
        This may, in situations of overabundant data,
        improve plot's interactive performance and avoid browser's
        `Javascript Error: Maximum call stack size exceeded` or similar.
        Equity & dropdown curves and individual trades data is,
        likewise, [reasonably _aggregated_][TRADES_AGG].
        `resample` can also be a [Pandas offset string],
        such as `'5T'` or `'5min'`, in which case this frequency will be
        used to resample, overriding above numeric limitation.
        Note, all this only works for data with a datetime index.

        If `reverse_indicators` is `True`, the indicators below the OHLC chart
        are plotted in reverse order of declaration.

        [Pandas offset string]: \
            https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#dateoffset-objects

        [TRADES_AGG]: lib.html#backtesting.lib.TRADES_AGG

        If `show_legend` is `True`, the resulting plot graphs will contain
        labeled legends.

        If `open_browser` is `True`, the resulting `filename` will be
        opened in the default web browser.
        """
        if results is None:
            if self._results is None:
                raise RuntimeError('First issue `backtest.run()` to obtain results.')
            results = self._results

        return plot(
            results=results,
            df=self._data,
            indicators=results._strategy._indicators,
            filename=filename,
            plot_width=plot_width,
            plot_equity=plot_equity,
            plot_return=plot_return,
            plot_pl=plot_pl,
            plot_volume=plot_volume,
            plot_drawdown=plot_drawdown,
            plot_trades=plot_trades,
            smooth_equity=smooth_equity,
            relative_equity=relative_equity,
            superimpose=superimpose,
            resample=resample,
            reverse_indicators=reverse_indicators,
            show_legend=show_legend,
            open_browser=open_browser)
#region imports
from AlgorithmImports import *
#endregion
"""
Collection of common building blocks, helper auxiliary functions and
composable strategy classes for reuse.

Intended for simple missing-link procedures, not reinventing
of better-suited, state-of-the-art, fast libraries,
such as TA-Lib, Tulipy, PyAlgoTrade, NumPy, SciPy ...

Please raise ideas for additions to this collection on the [issue tracker].

[issue tracker]: https://github.com/kernc/backtesting.py
"""

from collections import OrderedDict
from inspect import currentframe
from itertools import compress
from numbers import Number
from typing import Callable, Optional, Sequence, Union

import numpy as np
import pandas as pd

from ._plotting import plot_heatmaps as _plot_heatmaps
from ._stats import compute_stats as _compute_stats
from ._util import _Array, _as_str
from .strategy import Strategy

__pdoc__ = {}


OHLCV_AGG = OrderedDict((
    ('Open', 'first'),
    ('High', 'max'),
    ('Low', 'min'),
    ('Close', 'last'),
    ('Volume', 'sum'),
))
"""Dictionary of rules for aggregating resampled OHLCV data frames,
e.g.

    df.resample('4H', label='right').agg(OHLCV_AGG).dropna()
"""

TRADES_AGG = OrderedDict((
    ('Size', 'sum'),
    ('EntryBar', 'first'),
    ('ExitBar', 'last'),
    ('EntryPrice', 'mean'),
    ('ExitPrice', 'mean'),
    ('PnL', 'sum'),
    ('ReturnPct', 'mean'),
    ('EntryTime', 'first'),
    ('ExitTime', 'last'),
    ('Duration', 'sum'),
))
"""Dictionary of rules for aggregating resampled trades data,
e.g.

    stats['_trades'].resample('1D', on='ExitTime',
                              label='right').agg(TRADES_AGG)
"""

_EQUITY_AGG = {
    'Equity': 'last',
    'DrawdownPct': 'max',
    'DrawdownDuration': 'max',
}


def barssince(condition: Sequence[bool], default=np.inf) -> int:
    """
    Return the number of bars since `condition` sequence was last `True`,
    or if never, return `default`.

        >>> barssince(self.data.Close > self.data.Open)
        3
    """
    return next(compress(range(len(condition)), reversed(condition)), default)


def cross(series1: Sequence, series2: Sequence) -> bool:
    """
    Return `True` if `series1` and `series2` just crossed
    (above or below) each other.

        >>> cross(self.data.Close, self.sma)
        True

    """
    return crossover(series1, series2) or crossover(series2, series1)


def crossover(series1: Sequence, series2: Sequence) -> bool:
    """
    Return `True` if `series1` just crossed over (above)
    `series2`.

        >>> crossover(self.data.Close, self.sma)
        True
    """
    series1 = (
        series1.values if isinstance(series1, pd.Series) else
        (series1, series1) if isinstance(series1, Number) else
        series1)
    series2 = (
        series2.values if isinstance(series2, pd.Series) else
        (series2, series2) if isinstance(series2, Number) else
        series2)
    try:
        return series1[-2] < series2[-2] and series1[-1] > series2[-1]
    except IndexError:
        return False


def plot_heatmaps(heatmap: pd.Series,
                  agg: Union[str, Callable] = 'max',
                  *,
                  ncols: int = 3,
                  plot_width: int = 1200,
                  filename: str = '',
                  open_browser: bool = True):
    """
    Plots a grid of heatmaps, one for every pair of parameters in `heatmap`.

    `heatmap` is a Series as returned by
    `backtesting.backtesting.Backtest.optimize` when its parameter
    `return_heatmap=True`.

    When projecting the n-dimensional heatmap onto 2D, the values are
    aggregated by 'max' function by default. This can be tweaked
    with `agg` parameter, which accepts any argument pandas knows
    how to aggregate by.

    .. todo::
        Lay heatmaps out lower-triangular instead of in a simple grid.
        Like [`skopt.plots.plot_objective()`][plot_objective] does.

    [plot_objective]: \
        https://scikit-optimize.github.io/stable/modules/plots.html#plot-objective
    """
    return _plot_heatmaps(heatmap, agg, ncols, filename, plot_width, open_browser)


def quantile(series: Sequence, quantile: Union[None, float] = None):
    """
    If `quantile` is `None`, return the quantile _rank_ of the last
    value of `series` wrt former series values.

    If `quantile` is a value between 0 and 1, return the _value_ of
    `series` at this quantile. If used to working with percentiles, just
    divide your percentile amount with 100 to obtain quantiles.

        >>> quantile(self.data.Close[-20:], .1)
        162.130
        >>> quantile(self.data.Close)
        0.13
    """
    if quantile is None:
        try:
            last, series = series[-1], series[:-1]
            return np.mean(series < last)
        except IndexError:
            return np.nan
    assert 0 <= quantile <= 1, "quantile must be within [0, 1]"
    return np.nanpercentile(series, quantile * 100)


def compute_stats(
        *,
        stats: pd.Series,
        data: pd.DataFrame,
        trades: pd.DataFrame = None,
        risk_free_rate: float = 0.) -> pd.Series:
    """
    (Re-)compute strategy performance metrics.

    `stats` is the statistics series as returned by `backtesting.backtesting.Backtest.run()`.
    `data` is OHLC data as passed to the `backtesting.backtesting.Backtest`
    the `stats` were obtained in.
    `trades` can be a dataframe subset of `stats._trades` (e.g. only long trades).
    You can also tune `risk_free_rate`, used in calculation of Sharpe and Sortino ratios.

        >>> stats = Backtest(GOOG, MyStrategy).run()
        >>> only_long_trades = stats._trades[stats._trades.Size > 0]
        >>> long_stats = compute_stats(stats=stats, trades=only_long_trades,
        ...                            data=GOOG, risk_free_rate=.02)
    """
    equity = stats._equity_curve.Equity
    if trades is None:
        trades = stats._trades
    else:
        # XXX: Is this buggy?
        equity = equity.copy()
        equity[:] = stats._equity_curve.Equity.iloc[0]
        for t in trades.itertuples(index=False):
            equity.iloc[t.EntryBar:] += t.PnL
    return _compute_stats(trades=trades, equity=equity, ohlc_data=data,
                          risk_free_rate=risk_free_rate, strategy_instance=stats._strategy)


def resample_apply(rule: str,
                   func: Optional[Callable[..., Sequence]],
                   series: Union[pd.Series, pd.DataFrame, _Array],
                   *args,
                   agg: Optional[Union[str, dict]] = None,
                   **kwargs):
    """
    Apply `func` (such as an indicator) to `series`, resampled to
    a time frame specified by `rule`. When called from inside
    `backtesting.backtesting.Strategy.init`,
    the result (returned) series will be automatically wrapped in
    `backtesting.backtesting.Strategy.I`
    wrapper method.

    `rule` is a valid [Pandas offset string] indicating
    a time frame to resample `series` to.

    [Pandas offset string]: \
http://pandas.pydata.org/pandas-docs/stable/timeseries.html#offset-aliases

    `func` is the indicator function to apply on the resampled series.

    `series` is a data series (or array), such as any of the
    `backtesting.backtesting.Strategy.data` series. Due to pandas
    resampling limitations, this only works when input series
    has a datetime index.

    `agg` is the aggregation function to use on resampled groups of data.
    Valid values are anything accepted by `pandas/resample/.agg()`.
    Default value for dataframe input is `OHLCV_AGG` dictionary.
    Default value for series input is the appropriate entry from `OHLCV_AGG`
    if series has a matching name, or otherwise the value `"last"`,
    which is suitable for closing prices,
    but you might prefer another (e.g. `"max"` for peaks, or similar).

    Finally, any `*args` and `**kwargs` that are not already eaten by
    implicit `backtesting.backtesting.Strategy.I` call
    are passed to `func`.

    For example, if we have a typical moving average function
    `SMA(values, lookback_period)`, _hourly_ data source, and need to
    apply the moving average MA(10) on a _daily_ time frame,
    but don't want to plot the resulting indicator, we can do:

        class System(Strategy):
            def init(self):
                self.sma = resample_apply(
                    'D', SMA, self.data.Close, 10, plot=False)

    The above short snippet is roughly equivalent to:

        class System(Strategy):
            def init(self):
                # Strategy exposes `self.data` as raw NumPy arrays.
                # Let's convert closing prices back to pandas Series.
                close = self.data.Close.s

                # Resample to daily resolution. Aggregate groups
                # using their last value (i.e. closing price at the end
                # of the day). Notice `label='right'`. If it were set to
                # 'left' (default), the strategy would exhibit
                # look-ahead bias.
                daily = close.resample('D', label='right').agg('last')

                # We apply SMA(10) to daily close prices,
                # then reindex it back to original hourly index,
                # forward-filling the missing values in each day.
                # We make a separate function that returns the final
                # indicator array.
                def SMA(series, n):
                    from backtesting.test import SMA
                    return SMA(series, n).reindex(close.index).ffill()

                # The result equivalent to the short example above:
                self.sma = self.I(SMA, daily, 10, plot=False)

    """
    if func is None:
        def func(x, *_, **__):
            return x

    if not isinstance(series, (pd.Series, pd.DataFrame)):
        assert isinstance(series, _Array), \
            'resample_apply() takes either a `pd.Series`, `pd.DataFrame`, ' \
            'or a `Strategy.data.*` array'
        series = series.s

    if agg is None:
        agg = OHLCV_AGG.get(getattr(series, 'name', ''), 'last')
        if isinstance(series, pd.DataFrame):
            agg = {column: OHLCV_AGG.get(column, 'last')
                   for column in series.columns}

    resampled = series.resample(rule, label='right').agg(agg).dropna()
    resampled.name = _as_str(series) + '[' + rule + ']'

    # Check first few stack frames if we are being called from
    # inside Strategy.init, and if so, extract Strategy.I wrapper.
    frame, level = currentframe(), 0
    while frame and level <= 3:
        frame = frame.f_back
        level += 1
        if isinstance(frame.f_locals.get('self'), Strategy):  # type: ignore
            strategy_I = frame.f_locals['self'].I             # type: ignore
            break
    else:
        def strategy_I(func, *args, **kwargs):
            return func(*args, **kwargs)

    def wrap_func(resampled, *args, **kwargs):
        result = func(resampled, *args, **kwargs)
        if not isinstance(result, pd.DataFrame) and not isinstance(result, pd.Series):
            result = np.asarray(result)
            if result.ndim == 1:
                result = pd.Series(result, name=resampled.name)
            elif result.ndim == 2:
                result = pd.DataFrame(result.T)
        # Resample back to data index
        if not isinstance(result.index, pd.DatetimeIndex):
            result.index = resampled.index
        result = result.reindex(index=series.index.union(resampled.index),
                                method='ffill').reindex(series.index)
        return result

    wrap_func.__name__ = func.__name__

    array = strategy_I(wrap_func, resampled, *args, **kwargs)
    return array


def random_ohlc_data(example_data: pd.DataFrame, *,
                     frac=1., random_state: Optional[int] = None) -> pd.DataFrame:
    """
    OHLC data generator. The generated OHLC data has basic
    [descriptive statistics](https://en.wikipedia.org/wiki/Descriptive_statistics)
    similar to the provided `example_data`.

    `frac` is a fraction of data to sample (with replacement). Values greater
    than 1 result in oversampling.

    Such random data can be effectively used for stress testing trading
    strategy robustness, Monte Carlo simulations, significance testing, etc.

    >>> from backtesting.test import EURUSD
    >>> ohlc_generator = random_ohlc_data(EURUSD)
    >>> next(ohlc_generator)  # returns new random data
    ...
    >>> next(ohlc_generator)  # returns new random data
    ...
    """
    def shuffle(x):
        return x.sample(frac=frac, replace=frac > 1, random_state=random_state)

    if len(example_data.columns.intersection({'Open', 'High', 'Low', 'Close'})) != 4:
        raise ValueError("`data` must be a pandas.DataFrame with columns "
                         "'Open', 'High', 'Low', 'Close'")
    while True:
        df = shuffle(example_data)
        df.index = example_data.index
        padding = df.Close - df.Open.shift(-1)
        gaps = shuffle(example_data.Open.shift(-1) - example_data.Close)
        deltas = (padding + gaps).shift(1).fillna(0).cumsum()
        for key in ('Open', 'High', 'Low', 'Close'):
            df[key] += deltas
        yield df


class SignalStrategy(Strategy):
    """
    A simple helper strategy that operates on position entry/exit signals.
    This makes the backtest of the strategy simulate a [vectorized backtest].
    See [tutorials] for usage examples.

    [vectorized backtest]: https://www.google.com/search?q=vectorized+backtest
    [tutorials]: index.html#tutorials

    To use this helper strategy, subclass it, override its
    `backtesting.backtesting.Strategy.init` method,
    and set the signal vector by calling
    `backtesting.lib.SignalStrategy.set_signal` method from within it.

        class ExampleStrategy(SignalStrategy):
            def init(self):
                super().init()
                self.set_signal(sma1 > sma2, sma1 < sma2)

    Remember to call `super().init()` and `super().next()` in your
    overridden methods.
    """
    __entry_signal = (0,)
    __exit_signal = (False,)

    def set_signal(self, entry_size: Sequence[float],
                   exit_portion: Optional[Sequence[float]] = None,
                   *,
                   plot: bool = True):
        """
        Set entry/exit signal vectors (arrays).

        A long entry signal is considered present wherever `entry_size`
        is greater than zero, and a short signal wherever `entry_size`
        is less than zero, following `backtesting.backtesting.Order.size` semantics.

        If `exit_portion` is provided, a nonzero value closes portion the position
        (see `backtesting.backtesting.Trade.close()`) in the respective direction
        (positive values close long trades, negative short).

        If `plot` is `True`, the signal entry/exit indicators are plotted when
        `backtesting.backtesting.Backtest.plot` is called.
        """
        self.__entry_signal = self.I(  # type: ignore
            lambda: pd.Series(entry_size, dtype=float).replace(0, np.nan),
            name='entry size', plot=plot, overlay=False, scatter=True, color='black')

        if exit_portion is not None:
            self.__exit_signal = self.I(  # type: ignore
                lambda: pd.Series(exit_portion, dtype=float).replace(0, np.nan),
                name='exit portion', plot=plot, overlay=False, scatter=True, color='black')

    def next(self):
        super().next()

        exit_portion = self.__exit_signal[-1]
        if exit_portion > 0:
            for trade in self.trades:
                if trade.is_long:
                    trade.close(exit_portion)
        elif exit_portion < 0:
            for trade in self.trades:
                if trade.is_short:
                    trade.close(-exit_portion)

        entry_size = self.__entry_signal[-1]
        if entry_size > 0:
            self.buy(size=entry_size)
        elif entry_size < 0:
            self.sell(size=-entry_size)


class TrailingStrategy(Strategy):
    """
    A strategy with automatic trailing stop-loss, trailing the current
    price at distance of some multiple of average true range (ATR). Call
    `TrailingStrategy.set_trailing_sl()` to set said multiple
    (`6` by default). See [tutorials] for usage examples.

    [tutorials]: index.html#tutorials

    Remember to call `super().init()` and `super().next()` in your
    overridden methods.
    """
    __n_atr = 6.
    __atr = None

    def init(self):
        super().init()
        self.set_atr_periods()

    def set_atr_periods(self, periods: int = 100):
        """
        Set the lookback period for computing ATR. The default value
        of 100 ensures a _stable_ ATR.
        """
        hi, lo, c_prev = self.data.High, self.data.Low, pd.Series(self.data.Close).shift(1)
        tr = np.max([hi - lo, (c_prev - hi).abs(), (c_prev - lo).abs()], axis=0)
        atr = pd.Series(tr).rolling(periods).mean().bfill().values
        self.__atr = atr

    def set_trailing_sl(self, n_atr: float = 6):
        """
        Sets the future trailing stop-loss as some multiple (`n_atr`)
        average true bar ranges away from the current price.
        """
        self.__n_atr = n_atr

    def next(self):
        super().next()
        # Can't use index=-1 because self.__atr is not an Indicator type
        index = len(self.data)-1
        for trade in self.trades:
            if trade.is_long:
                trade.sl = max(trade.sl or -np.inf,
                               self.data.Close[index] - self.__atr[index] * self.__n_atr)
            else:
                trade.sl = min(trade.sl or np.inf,
                               self.data.Close[index] + self.__atr[index] * self.__n_atr)


# Prevent pdoc3 documenting __init__ signature of Strategy subclasses
for cls in list(globals().values()):
    if isinstance(cls, type) and issubclass(cls, Strategy):
        __pdoc__[f'{cls.__name__}.__init__'] = False


# NOTE: Don't put anything below this __all__ list

__all__ = [getattr(v, '__name__', k)
           for k, v in globals().items()                        # export
           if ((callable(v) and v.__module__ == __name__ or     # callables from this module
                k.isupper()) and                                # or CONSTANTS
               not getattr(v, '__name__', k).startswith('_'))]  # neither marked internal

# NOTE: Don't put anything below here. See above.

#region imports
from AlgorithmImports import *
#endregion
from typing import Optional
# from .trade import Trade
# from .backtesting import __pdoc__#, _Broker

__pdoc__ = {
    'Strategy.__init__': False,
    'Order.__init__': False,
    'Position.__init__': False,
    'Trade.__init__': False,
}

class Order:
    """
    Place new orders through `Strategy.buy()` and `Strategy.sell()`.
    Query existing orders through `Strategy.orders`.

    When an order is executed or [filled], it results in a `Trade`.

    If you wish to modify aspects of a placed but not yet filled order,
    cancel it and place a new one instead.

    All placed orders are [Good 'Til Canceled].

    [filled]: https://www.investopedia.com/terms/f/fill.asp
    [Good 'Til Canceled]: https://www.investopedia.com/terms/g/gtc.asp
    """
    def __init__(self, broker: '_Broker',
                 size: float,
                 limit_price: Optional[float] = None,
                 stop_price: Optional[float] = None,
                 sl_price: Optional[float] = None,
                 tp_price: Optional[float] = None,
                 parent_trade: Optional['Trade'] = None,
                 tag: object = None):
        self.__broker = broker
        assert size != 0
        self.__size = size
        self.__limit_price = limit_price
        self.__stop_price = stop_price
        self.__sl_price = sl_price
        self.__tp_price = tp_price
        self.__parent_trade = parent_trade
        self.__tag = tag

    def _replace(self, **kwargs):
        for k, v in kwargs.items():
            setattr(self, f'_{self.__class__.__qualname__}__{k}', v)
        return self

    def __repr__(self):
        return '<Order {}>'.format(', '.join(f'{param}={round(value, 5)}'
                                             for param, value in (
                                                 ('size', self.__size),
                                                 ('limit', self.__limit_price),
                                                 ('stop', self.__stop_price),
                                                 ('sl', self.__sl_price),
                                                 ('tp', self.__tp_price),
                                                 ('contingent', self.is_contingent),
                                                 ('tag', self.__tag),
                                             ) if value is not None))

    def cancel(self):
        """Cancel the order."""
        self.__broker.orders.remove(self)
        trade = self.__parent_trade
        if trade:
            if self is trade._sl_order:
                trade._replace(sl_order=None)
            elif self is trade._tp_order:
                trade._replace(tp_order=None)
            else:
                # XXX: https://github.com/kernc/backtesting.py/issues/251#issuecomment-835634984 ???
                assert False

    # Fields getters

    @property
    def size(self) -> float:
        """
        Order size (negative for short orders).

        If size is a value between 0 and 1, it is interpreted as a fraction of current
        available liquidity (cash plus `Position.pl` minus used margin).
        A value greater than or equal to 1 indicates an absolute number of units.
        """
        return self.__size

    @property
    def limit(self) -> Optional[float]:
        """
        Order limit price for [limit orders], or None for [market orders],
        which are filled at next available price.

        [limit orders]: https://www.investopedia.com/terms/l/limitorder.asp
        [market orders]: https://www.investopedia.com/terms/m/marketorder.asp
        """
        return self.__limit_price

    @property
    def stop(self) -> Optional[float]:
        """
        Order stop price for [stop-limit/stop-market][_] order,
        otherwise None if no stop was set, or the stop price has already been hit.

        [_]: https://www.investopedia.com/terms/s/stoporder.asp
        """
        return self.__stop_price

    @property
    def sl(self) -> Optional[float]:
        """
        A stop-loss price at which, if set, a new contingent stop-market order
        will be placed upon the `Trade` following this order's execution.
        See also `Trade.sl`.
        """
        return self.__sl_price

    @property
    def tp(self) -> Optional[float]:
        """
        A take-profit price at which, if set, a new contingent limit order
        will be placed upon the `Trade` following this order's execution.
        See also `Trade.tp`.
        """
        return self.__tp_price

    @property
    def parent_trade(self):
        return self.__parent_trade

    @property
    def tag(self):
        """
        Arbitrary value (such as a string) which, if set, enables tracking
        of this order and the associated `Trade` (see `Trade.tag`).
        """
        return self.__tag

    __pdoc__['Order.parent_trade'] = False

    # Extra properties

    @property
    def is_long(self):
        """True if the order is long (order size is positive)."""
        return self.__size > 0

    @property
    def is_short(self):
        """True if the order is short (order size is negative)."""
        return self.__size < 0

    @property
    def is_contingent(self):
        """
        True for [contingent] orders, i.e. [OCO] stop-loss and take-profit bracket orders
        placed upon an active trade. Remaining contingent orders are canceled when
        their parent `Trade` is closed.

        You can modify contingent orders through `Trade.sl` and `Trade.tp`.

        [contingent]: https://www.investopedia.com/terms/c/contingentorder.asp
        [OCO]: https://www.investopedia.com/terms/o/oco.asp
        """
        return bool(self.__parent_trade)


#region imports
from AlgorithmImports import *
#endregion
import numpy as np
# from .backtesting import _Broker

class Position:
    """
    Currently held asset position, available as
    `backtesting.backtesting.Strategy.position` within
    `backtesting.backtesting.Strategy.next`.
    Can be used in boolean contexts, e.g.

        if self.position:
            ...  # we have a position, either long or short
    """
    def __init__(self, broker: '_Broker'):
        self.__broker = broker

    def __bool__(self):
        return self.size != 0

    @property
    def size(self) -> float:
        """Position size in units of asset. Negative if position is short."""
        return sum(trade.size for trade in self.__broker.trades)

    @property
    def pl(self) -> float:
        """Profit (positive) or loss (negative) of the current position in cash units."""
        return sum(trade.pl for trade in self.__broker.trades)

    @property
    def pl_pct(self) -> float:
        """Profit (positive) or loss (negative) of the current position in percent."""
        weights = np.abs([trade.size for trade in self.__broker.trades])
        weights = weights / weights.sum()
        pl_pcts = np.array([trade.pl_pct for trade in self.__broker.trades])
        return (pl_pcts * weights).sum()

    @property
    def is_long(self) -> bool:
        """True if the position is long (position size is positive)."""
        return self.size > 0

    @property
    def is_short(self) -> bool:
        """True if the position is short (position size is negative)."""
        return self.size < 0

    def close(self, portion: float = 1.):
        """
        Close portion of position by closing `portion` of each active trade. See `Trade.close`.
        """
        for trade in self.__broker.trades:
            trade.close(portion)

    def __repr__(self):
        return f'<Position: {self.size} ({len(self.__broker.trades)} trades)>'


#region imports
from AlgorithmImports import *
#endregion
from abc import ABCMeta, abstractmethod
from ._util import _as_str, _Indicator, _Data, try_
from typing import Callable, Optional, Tuple
import numpy as np
import pandas as pd
import sys
from itertools import chain
from .position import Position
from .order import Order
from .trade import Trade
# from .backtesting import _Broker

class _Orders(tuple):
    """
    TODO: remove this class. Only for deprecation.
    """
    def cancel(self):
        """Cancel all non-contingent (i.e. SL/TP) orders."""
        for order in self:
            if not order.is_contingent:
                order.cancel()

    def __getattr__(self, item):
        # TODO: Warn on deprecations from the previous version. Remove in the next.
        removed_attrs = ('entry', 'set_entry', 'is_long', 'is_short',
                         'sl', 'tp', 'set_sl', 'set_tp')
        if item in removed_attrs:
            raise AttributeError(f'Strategy.orders.{"/.".join(removed_attrs)} were removed in'
                                 'Backtesting 0.2.0. '
                                 'Use `Order` API instead. See docs.')
        raise AttributeError(f"'tuple' object has no attribute {item!r}")

class Strategy(metaclass=ABCMeta):
    """
    A trading strategy base class. Extend this class and
    override methods
    `backtesting.backtesting.Strategy.init` and
    `backtesting.backtesting.Strategy.next` to define
    your own strategy.
    """
    def __init__(self, broker, data, params):
        self._indicators = []
        self._broker: _Broker = broker
        self._data: _Data = data
        self._params = self._check_params(params)

    def __repr__(self):
        return '<Strategy ' + str(self) + '>'

    def __str__(self):
        params = ','.join(f'{i[0]}={i[1]}' for i in zip(self._params.keys(),
                                                        map(_as_str, self._params.values())))
        if params:
            params = '(' + params + ')'
        return f'{self.__class__.__name__}{params}'

    def _check_params(self, params):
        for k, v in params.items():
            if not hasattr(self, k):
                raise AttributeError(
                    f"Strategy '{self.__class__.__name__}' is missing parameter '{k}'."
                    "Strategy class should define parameters as class variables before they "
                    "can be optimized or run with.")
            setattr(self, k, v)
        return params

    def I(self,  # noqa: E743
          func: Callable, *args,
          name=None, plot=True, overlay=None, color=None, scatter=False,
          **kwargs) -> np.ndarray:
        """
        Declare an indicator. An indicator is just an array of values,
        but one that is revealed gradually in
        `backtesting.backtesting.Strategy.next` much like
        `backtesting.backtesting.Strategy.data` is.
        Returns `np.ndarray` of indicator values.

        `func` is a function that returns the indicator array(s) of
        same length as `backtesting.backtesting.Strategy.data`.

        In the plot legend, the indicator is labeled with
        function name, unless `name` overrides it.

        If `plot` is `True`, the indicator is plotted on the resulting
        `backtesting.backtesting.Backtest.plot`.

        If `overlay` is `True`, the indicator is plotted overlaying the
        price candlestick chart (suitable e.g. for moving averages).
        If `False`, the indicator is plotted standalone below the
        candlestick chart. By default, a heuristic is used which decides
        correctly most of the time.

        `color` can be string hex RGB triplet or X11 color name.
        By default, the next available color is assigned.

        If `scatter` is `True`, the plotted indicator marker will be a
        circle instead of a connected line segment (default).

        Additional `*args` and `**kwargs` are passed to `func` and can
        be used for parameters.

        For example, using simple moving average function from TA-Lib:

            def init():
                self.sma = self.I(ta.SMA, self.data.Close, self.n_sma)
        """
        if name is None:
            params = ','.join(filter(None, map(_as_str, chain(args, kwargs.values()))))
            func_name = _as_str(func)
            name = (f'{func_name}({params})' if params else f'{func_name}')
        else:
            name = name.format(*map(_as_str, args),
                               **dict(zip(kwargs.keys(), map(_as_str, kwargs.values()))))

        try:
            value = func(*args, **kwargs)
        except Exception as e:
            raise RuntimeError(f'Indicator "{name}" error') from e

        if isinstance(value, pd.DataFrame):
            value = value.values.T

        if value is not None:
            value = try_(lambda: np.asarray(value, order='C'), None)
        is_arraylike = bool(value is not None and value.shape)

        # Optionally flip the array if the user returned e.g. `df.values`
        if is_arraylike and np.argmax(value.shape) == 0:
            value = value.T

        if not is_arraylike or not 1 <= value.ndim <= 2 or value.shape[-1] != len(self._data.Close):
            raise ValueError(
                'Indicators must return (optionally a tuple of) numpy.arrays of same '
                f'length as `data` (data shape: {self._data.Close.shape}; indicator "{name}" '
                f'shape: {getattr(value, "shape" , "")}, returned value: {value})')

        if plot and overlay is None and np.issubdtype(value.dtype, np.number):
            x = value / self._data.Close
            # By default, overlay if strong majority of indicator values
            # is within 30% of Close
            with np.errstate(invalid='ignore'):
                overlay = ((x < 1.4) & (x > .6)).mean() > .6

        value = _Indicator(value, name=name, plot=plot, overlay=overlay,
                           color=color, scatter=scatter,
                           # _Indicator.s Series accessor uses this:
                           index=self.data.index)
        self._indicators.append(value)
        return value

    @abstractmethod
    def init(self):
        """
        Initialize the strategy.
        Override this method.
        Declare indicators (with `backtesting.backtesting.Strategy.I`).
        Precompute what needs to be precomputed or can be precomputed
        in a vectorized fashion before the strategy starts.

        If you extend composable strategies from `backtesting.lib`,
        make sure to call:

            super().init()
        """

    @abstractmethod
    def next(self):
        """
        Main strategy runtime method, called as each new
        `backtesting.backtesting.Strategy.data`
        instance (row; full candlestick bar) becomes available.
        This is the main method where strategy decisions
        upon data precomputed in `backtesting.backtesting.Strategy.init`
        take place.

        If you extend composable strategies from `backtesting.lib`,
        make sure to call:

            super().next()
        """

    class __FULL_EQUITY(float):  # noqa: N801
        def __repr__(self): return '.9999'
    _FULL_EQUITY = __FULL_EQUITY(1 - sys.float_info.epsilon)

    def buy(self, *,
            size: float = _FULL_EQUITY,
            limit: Optional[float] = None,
            stop: Optional[float] = None,
            sl: Optional[float] = None,
            tp: Optional[float] = None,
            tag: object = None):
        """
        Place a new long order. For explanation of parameters, see `Order` and its properties.

        See `Position.close()` and `Trade.close()` for closing existing positions.

        See also `Strategy.sell()`.
        """
        assert 0 < size < 1 or round(size) == size, \
            "size must be a positive fraction of equity, or a positive whole number of units"
        return self._broker.new_order(size, limit, stop, sl, tp, tag)

    def sell(self, *,
             size: float = _FULL_EQUITY,
             limit: Optional[float] = None,
             stop: Optional[float] = None,
             sl: Optional[float] = None,
             tp: Optional[float] = None,
             tag: object = None):
        """
        Place a new short order. For explanation of parameters, see `Order` and its properties.

        See also `Strategy.buy()`.

        .. note::
            If you merely want to close an existing long position,
            use `Position.close()` or `Trade.close()`.
        """
        assert 0 < size < 1 or round(size) == size, \
            "size must be a positive fraction of equity, or a positive whole number of units"
        return self._broker.new_order(-size, limit, stop, sl, tp, tag)

    @property
    def equity(self) -> float:
        """Current account equity (cash plus assets)."""
        return self._broker.equity

    @property
    def data(self) -> _Data:
        """
        Price data, roughly as passed into
        `backtesting.backtesting.Backtest.__init__`,
        but with two significant exceptions:

        * `data` is _not_ a DataFrame, but a custom structure
          that serves customized numpy arrays for reasons of performance
          and convenience. Besides OHLCV columns, `.index` and length,
          it offers `.pip` property, the smallest price unit of change.
        * Within `backtesting.backtesting.Strategy.init`, `data` arrays
          are available in full length, as passed into
          `backtesting.backtesting.Backtest.__init__`
          (for precomputing indicators and such). However, within
          `backtesting.backtesting.Strategy.next`, `data` arrays are
          only as long as the current iteration, simulating gradual
          price point revelation. In each call of
          `backtesting.backtesting.Strategy.next` (iteratively called by
          `backtesting.backtesting.Backtest` internally),
          the last array value (e.g. `data.Close[-1]`)
          is always the _most recent_ value.
        * If you need data arrays (e.g. `data.Close`) to be indexed
          **Pandas series**, you can call their `.s` accessor
          (e.g. `data.Close.s`). If you need the whole of data
          as a **DataFrame**, use `.df` accessor (i.e. `data.df`).
        """
        return self._data

    @property
    def position(self) -> 'Position':
        """Instance of `backtesting.backtesting.Position`."""
        return self._broker.position

    @property
    def orders(self) -> 'Tuple[Order, ...]':
        """List of orders (see `Order`) waiting for execution."""
        return _Orders(self._broker.orders)

    @property
    def trades(self) -> 'Tuple[Trade, ...]':
        """List of active trades (see `Trade`)."""
        return tuple(self._broker.trades)

    @property
    def closed_trades(self) -> 'Tuple[Trade, ...]':
        """List of settled trades (see `Trade`)."""
        return tuple(self._broker.closed_trades)

#region imports
from AlgorithmImports import *
#endregion
from typing import Optional, Union
from .order import Order
from math import copysign
import pandas as pd
from copy import copy
import numpy as np
# from .backtesting import _Broker

class Trade:
    """
    When an `Order` is filled, it results in an active `Trade`.
    Find active trades in `Strategy.trades` and closed, settled trades in `Strategy.closed_trades`.
    """
    def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar, tag):
        self.__broker = broker
        self.__size = size
        self.__entry_price = entry_price
        self.__exit_price: Optional[float] = None
        self.__entry_bar: int = entry_bar
        self.__exit_bar: Optional[int] = None
        self.__sl_order: Optional[Order] = None
        self.__tp_order: Optional[Order] = None
        self.__tag = tag

    def __repr__(self):
        return f'<Trade size={self.__size} time={self.__entry_bar}-{self.__exit_bar or ""} ' \
               f'price={self.__entry_price}-{self.__exit_price or ""} pl={self.pl:.0f}' \
               f'{" tag="+str(self.__tag) if self.__tag is not None else ""}>'

    def _replace(self, **kwargs):
        for k, v in kwargs.items():
            setattr(self, f'_{self.__class__.__qualname__}__{k}', v)
        return self

    def _copy(self, **kwargs):
        return copy(self)._replace(**kwargs)

    def close(self, portion: float = 1.):
        """Place new `Order` to close `portion` of the trade at next market price."""
        assert 0 < portion <= 1, "portion must be a fraction between 0 and 1"
        size = copysign(max(1, round(abs(self.__size) * portion)), -self.__size)
        order = Order(self.__broker, size, parent_trade=self, tag=self.__tag)
        self.__broker.orders.insert(0, order)

    # Fields getters

    @property
    def size(self):
        """Trade size (volume; negative for short trades)."""
        return self.__size

    @property
    def entry_price(self) -> float:
        """Trade entry price."""
        return self.__entry_price

    @property
    def exit_price(self) -> Optional[float]:
        """Trade exit price (or None if the trade is still active)."""
        return self.__exit_price

    @property
    def entry_bar(self) -> int:
        """Candlestick bar index of when the trade was entered."""
        return self.__entry_bar

    @property
    def exit_bar(self) -> Optional[int]:
        """
        Candlestick bar index of when the trade was exited
        (or None if the trade is still active).
        """
        return self.__exit_bar

    @property
    def tag(self):
        """
        A tag value inherited from the `Order` that opened
        this trade.

        This can be used to track trades and apply conditional
        logic / subgroup analysis.

        See also `Order.tag`.
        """
        return self.__tag

    @property
    def _sl_order(self):
        return self.__sl_order

    @property
    def _tp_order(self):
        return self.__tp_order

    # Extra properties

    @property
    def entry_time(self) -> Union[pd.Timestamp, int]:
        """Datetime of when the trade was entered."""
        return self.__broker._data.index[self.__entry_bar]

    @property
    def exit_time(self) -> Optional[Union[pd.Timestamp, int]]:
        """Datetime of when the trade was exited."""
        if self.__exit_bar is None:
            return None
        return self.__broker._data.index[self.__exit_bar]

    @property
    def is_long(self):
        """True if the trade is long (trade size is positive)."""
        return self.__size > 0

    @property
    def is_short(self):
        """True if the trade is short (trade size is negative)."""
        return not self.is_long

    @property
    def pl(self):
        """Trade profit (positive) or loss (negative) in cash units."""
        price = self.__exit_price or self.__broker.last_price
        return self.__size * (price - self.__entry_price)

    @property
    def pl_pct(self):
        """Trade profit (positive) or loss (negative) in percent."""
        price = self.__exit_price or self.__broker.last_price
        return copysign(1, self.__size) * (price / self.__entry_price - 1)

    @property
    def value(self):
        """Trade total value in cash (volume × price)."""
        price = self.__exit_price or self.__broker.last_price
        return abs(self.__size) * price

    # SL/TP management API

    @property
    def sl(self):
        """
        Stop-loss price at which to close the trade.

        This variable is writable. By assigning it a new price value,
        you create or modify the existing SL order.
        By assigning it `None`, you cancel it.
        """
        return self.__sl_order and self.__sl_order.stop

    @sl.setter
    def sl(self, price: float):
        self.__set_contingent('sl', price)

    @property
    def tp(self):
        """
        Take-profit price at which to close the trade.

        This property is writable. By assigning it a new price value,
        you create or modify the existing TP order.
        By assigning it `None`, you cancel it.
        """
        return self.__tp_order and self.__tp_order.limit

    @tp.setter
    def tp(self, price: float):
        self.__set_contingent('tp', price)

    def __set_contingent(self, type, price):
        assert type in ('sl', 'tp')
        assert price is None or 0 < price < np.inf
        attr = f'_{self.__class__.__qualname__}__{type}_order'
        order: Order = getattr(self, attr)
        if order:
            order.cancel()
        if price:
            kwargs = {'stop': price} if type == 'sl' else {'limit': price}
            order = self.__broker.new_order(-self.size, trade=self, tag=self.tag, **kwargs)
            setattr(self, attr, order)

#region imports
from AlgorithmImports import *
#endregion
"""
.. moduleauthor:: Paweł Knioła <pawel.kn@gmail.com>
"""

name = "btester"
__version__ = "0.1.1"

from .btester import *
#region imports
from AlgorithmImports import *
#endregion
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import List, Dict, Any, Type, Hashable, Optional
from math import nan, isnan
import pandas as pd

@dataclass
class Position:
    """
    Represents an open financial position.

    Attributes:
    - symbol: Optional[str] - Symbol of the financial instrument.
    - open_date: Optional[datetime] - Date when the position was opened.
    - last_date: Optional[datetime] - Date of the latest update to the position.
    - open_price: float - Price at which the position was opened.
    - last_price: float - Latest market price of the instrument.
    - position_size: float - Size of the position.
    - profit_loss: float - Cumulative profit or loss of the position.
    - change_pct: float - Percentage change in price since opening the position.
    - current_value: float - Current market value of the position.

    Methods:
    - update(last_date: datetime, last_price: float) - Update the position with the latest market data.
    """
    symbol: Optional[str] = None
    open_date: Optional[datetime] = None
    last_date: Optional[datetime] = None
    open_price: float = nan
    last_price: float = nan
    position_size: float = nan
    profit_loss: float = nan
    change_pct: float = nan
    current_value: float = nan

    def update(self, last_date: datetime, last_price: float):
        self.last_date = last_date
        self.last_price = last_price
        self.profit_loss = (self.last_price - self.open_price) * self.position_size
        self.change_pct = (self.last_price / self.open_price - 1) * 100
        self.current_value = self.open_price * self.position_size + self.profit_loss

@dataclass
class Trade:
    """
    Represents a completed financial transaction.

    Attributes:
    - symbol: Optional[str] - Symbol of the financial instrument.
    - open_date: Optional[datetime] - Date when the trade was opened.
    - close_date: Optional[datetime] - Date when the trade was closed.
    - open_price: float - Price at which the trade was opened.
    - close_price: float - Price at which the trade was closed.
    - position_size: float - Size of the traded position.
    - profit_loss: float - Cumulative profit or loss of the trade.
    - change_pct: float - Percentage change in price during the trade.
    - trade_commission: float - Commission paid for the trade.
    - cumulative_return: float - Cumulative return after the trade.
    """
    symbol: Optional[str] = None
    open_date: Optional[datetime] = None
    close_date: Optional[datetime] = None
    open_price: float = nan
    close_price: float = nan
    position_size: float = nan
    profit_loss: float = nan
    change_pct: float = nan
    trade_commission: float = nan
    cumulative_return: float = nan

@dataclass
class Result:
    """
    Container class for backtest results.

    Attributes:
    - returns: pd.Series - Time series of cumulative returns.
    - trades: List[Trade] - List of completed trades.
    - open_positions: List[Position] - List of remaining open positions.
    """
    returns: pd.Series
    trades: List[Trade]
    open_positions: List[Position]

class Strategy(ABC):
    """
    Abstract base class for implementing trading strategies.

    Methods:
    - init(self) - Abstract method for initializing resources for the strategy.
    - next(self, i: int, record: Dict[Hashable, Any]) - Abstract method defining the core functionality of the strategy.

    Attributes:
    - data: pd.DataFrame - Historical market data.
    - date: Optional[datetime] - Current date during backtesting.
    - cash: float - Available cash for trading.
    - commission: float - Commission rate for trades.
    - symbols: List[str] - List of symbols in the market data.
    - records: List[Dict[Hashable, Any]] - List of records representing market data.
    - index: List[datetime] - List of dates corresponding to market data.
    - returns: List[float] - List of cumulative returns during backtesting.
    - trades: List[Trade] - List of completed trades during backtesting.
    - open_positions: List[Position] - List of remaining open positions during backtesting.
    - cumulative_return: float - Cumulative return of the strategy.
    - assets_value: float - Market value of open positions.

    Methods:
    - open(self, price: float, size: Optional[float] = None, symbol: Optional[str] = None) -> bool
    - close(self, price: float, symbol: Optional[str] = None, position: Optional[Position] = None) -> bool
    """

    @abstractmethod
    def init(self):
        """
        Abstract method for initializing resources and parameters for the strategy.

        This method is called once at the beginning of the backtest to perform any necessary setup or configuration
        for the trading strategy. It allows the strategy to initialize variables, set parameters, or load external data
        needed for the strategy's functionality.

        Parameters:
        - *args: Additional positional arguments that can be passed during initialization.
        - **kwargs: Additional keyword arguments that can be passed during initialization.

        Example:
        ```python
        def init(self, buy_period: int, sell_period: int):
            self.buy_signal = {}
            self.sell_signal = {}

            for symbol in self.symbols:
                self.buy_signal[symbol] = UpBreakout(self.data[(symbol,'Close')], buy_period)
                self.sell_signal[symbol] = DownBreakout(self.data[(symbol,'Close')], sell_period)
        ```

        Note:
        It is recommended to define the expected parameters and their default values within the `init` method
        to allow flexibility and customization when initializing the strategy.
        """

    @abstractmethod
    def next(self, i: int, record: Dict[Hashable, Any]):
        """
        Abstract method defining the core functionality of the strategy for each time step.

        This method is called iteratively for each time step during the backtest, allowing the strategy to make
        decisions based on the current market data represented by the 'record'. It defines the core logic of the
        trading strategy, such as generating signals, managing positions, and making trading decisions.

        Parameters:
        - i (int): Index of the current time step.
        - record (Dict[Hashable, Any]): Dictionary representing the market data at the current time step.
          The keys can include symbols, and the values can include relevant market data (e.g., OHLC prices).

        Example:
        ```python
        def next(self, i, record):
            for symbol in self.symbols:
                if self.buy_signal[symbol][i-1]:
                    self.open(symbol=symbol, price=record[(symbol,'Open')], size=self.positionSize(record[(symbol,'Open')]))

            for position in self.open_positions[:]:
                if self.sell_signal[position.symbol][i-1]:
                    self.close(position=position, price=record[(position.symbol,'Open')])
        ```
        """

    def __init__(self):
        self.data = pd.DataFrame()
        self.date = None
        self.cash = .0
        self.commission = .0

        self.symbols: List[str] = []

        self.records: List[Dict[Hashable, Any]] = []
        self.index: List[datetime] = []

        self.returns: List[float] = []
        self.trades: List[Trade] = []
        self.open_positions: List[Position] = []

        self.cumulative_return = self.cash
        self.assets_value = .0

    def open(self, price: float, size: Optional[float] = None, symbol: Optional[str] = None):
        """
        Opens a new financial position based on the specified parameters.

        Parameters:
        - price: float - The price at which to open the position.
        - size: Optional[float] - The size of the position. If not provided, it is calculated based on available cash.
        - symbol: Optional[str] - Symbol of the financial instrument.

        Returns:
        - bool: True if the position was successfully opened, False otherwise.

        This method calculates the cost of opening a new position, checks if the specified size is feasible given
        available cash, and updates the strategy's open positions accordingly. It returns True if the position is
        successfully opened, and False otherwise.
        """
        if isnan(price) or price <= 0 or (size is not None and (isnan(size) or size <= .0)):
            return False

        if size is None:
            size = self.cash / (price * (1 + self.commission))
            open_cost = self.cash
        else:
            open_cost = size * price * (1 + self.commission)

        if isnan(size) or size <= .0 or self.cash < open_cost:
            return False

        position = Position(symbol=symbol, open_date=self.date, open_price=price, position_size=size)
        position.update(last_date=self.date, last_price=price)

        self.assets_value += position.current_value
        self.cash -= open_cost

        self.open_positions.extend([position])
        return True

    def close(self, price: float, symbol: Optional[str] = None, position: Optional[Position] = None):
        """
        Closes an existing financial position based on the specified parameters.

        Parameters:
        - price: float - The price at which to close the position.
        - symbol: Optional[str] - Symbol of the financial instrument.
        - position: Optional[Position] - The specific position to close. If not provided, closes all positions for the symbol.

        Returns:
        - bool: True if the position(s) were successfully closed, False otherwise.

        This method calculates the cost of closing a position, updates the strategy's cumulative return, and records the
        trade details. If a specific position is provided, only that position is closed. If no position is specified,
        all open positions for the specified symbol are closed. It returns True if the position(s) is successfully
        closed, and False otherwise.
        """
        if isnan(price) or price <= 0:
            return False

        if position is None:
            for position in self.open_positions[:]:
                if position.symbol == symbol:
                    self.close(position=position, price=price)
        else:
            self.assets_value -= position.current_value
            position.update(last_date=self.date, last_price=price)

            trade_commission = (position.open_price + position.last_price) * position.position_size * self.commission
            self.cumulative_return += position.profit_loss - trade_commission

            trade = Trade(position.symbol, position.open_date, position.last_date, position.open_price,
                position.last_price, position.position_size, position.profit_loss, position.change_pct,
                trade_commission, self.cumulative_return)

            self.trades.extend([trade])
            self.open_positions.remove(position)

            close_cost = position.last_price * position.position_size * self.commission
            self.cash += position.current_value - close_cost

        return True

    def __eval(self, *args, **kwargs):
        self.cumulative_return = self.cash
        self.assets_value = .0

        self.init(*args, **kwargs)

        for i, record in enumerate(self.records):
            self.date = self.index[i]

            self.next(i, record)

            for position in self.open_positions:
                last_price = record[(position.symbol, 'Close')] if (position.symbol, 'Close') in record else record['Close']
                if last_price > 0:
                    position.update(last_date=self.date, last_price=last_price)

            self.assets_value = sum(position.current_value for position in self.open_positions)
            self.returns.append(self.cash + self.assets_value)

        return Result(
            returns=pd.Series(index=self.index, data=self.returns, dtype=float),
            trades=self.trades,
            open_positions=self.open_positions
        )

class Backtest:
    """
    Class for running a backtest on a given strategy using historical market data.

    Attributes:
    - strategy: Type[Strategy] - Type of strategy to be backtested.
    - data: pd.DataFrame - Historical market data.
    - cash: float - Initial cash available for trading.
    - commission: float - Commission rate for trades.

    Methods:
    - run(*args, **kwargs) - Run the backtest and return the results.
    """
    def __init__(self,
                 strategy: Type[Strategy],
                 data: pd.DataFrame,
                 cash: float = 10_000,
                 commission: float = .0
                 ):

        self.strategy = strategy
        self.data = data
        self.cash = cash
        self.commission = commission

        columns = data.columns
        self.symbols = columns.get_level_values(0).unique().tolist() if isinstance(columns, pd.MultiIndex) else []

        self.records = data.to_dict('records')
        self.index = data.index.tolist()

    def run(self, *args, **kwargs):
        strategy = self.strategy()
        strategy.data = self.data
        strategy.cash = self.cash
        strategy.commission = self.commission

        strategy.symbols = self.symbols
        strategy.records = self.records
        strategy.index = self.index

        return strategy._Strategy__eval(*args, **kwargs)
# region imports
from AlgorithmImports import *
# endregion

import numpy as np
import pandas as pd
# The custom algo imports
from Execution import AutoExecutionModel, SmartPricingExecutionModel, SPXExecutionModel
from Monitor import HedgeRiskManagementModel, NoStopLossModel, StopLossModel, FPLMonitorModel, SPXicMonitor, CCMonitor, SPXButterflyMonitor, SPXCondorMonitor
from PortfolioConstruction import OptionsPortfolioConstruction
# The alpha models
from Alpha import FPLModel, CCModel, SPXic, SPXButterfly, SPXCondor
# The execution classes
from Initialization import SetupBaseStructure, HandleOrderEvents
from Tools import Performance


"""
Algorithm Structure Case v1:

1. We run the SetupBaseStructure.Setup() that will set the defaults for all the holders of data and base configuration
2. We have inside each AlphaModel a set of default parameters that will not be assigned to the context.
    - This means that each AlphaModel (Strategy) will have their own configuration defined in each class.
    - The AlphaModel will add the Underlying and options chains required
    - The QC algo will call the AlphaModel#Update method every 1 minute (self.timeResolution)
    - The Update method will call the AlphaModel#getOrder method
    - The getOrder method should use self.order (Alpha.Utils.Order) methods to get the options
    - The options returned will use the Alpha.Utils.Scanner and the Alpha.Utils.OrderBuilder classes
    - The final returned method requred to be returned by getOrder method is the Order#getOrderDetails
    - The Update method now in AlphaModel will use the getOrder method output to create Insights

"""

class CentralAlgorithm(QCAlgorithm):
    def Initialize(self):
        # WARNING!! If your are going to trade SPX 0DTE options then make sure you set the startDate after July 1st 2022.
        # This is the start of the data we have.
        self.SetStartDate(2023, 1, 1)
        self.SetEndDate(2023, 1, 29)
        # self.SetStartDate(2024, 4, 1)
        # self.SetEndDate(2024, 4, 30)
        # self.SetEndDate(2022, 9, 15)
        # Warmup for some days
        # self.SetWarmUp(timedelta(14))

        # Logging level:
        #  -> 0 = ERROR
        #  -> 1 = WARNING
        #  -> 2 = INFO
        #  -> 3 = DEBUG
        #  -> 4 = TRACE (Attention!! This can consume your entire daily log limit)
        self.logLevel = 3 if self.LiveMode else 2


        # Set the initial account value
        self.initialAccountValue = 100_000
        self.SetCash(self.initialAccountValue)

        # Time Resolution
        self.timeResolution = Resolution.Minute

        # Set Export method
        self.CSVExport = False
        # Should the trade log be displayed
        self.showTradeLog = False
        # Show the execution statistics
        self.showExecutionStats = False
        # Show the performance statistics
        self.showPerformanceStats = False

        # Set the algorithm base variables and structures
        self.structure = SetupBaseStructure(self).Setup()

        self.performance = Performance(self)

        # Set the algorithm framework models
        # self.SetAlpha(FPLModel(self))
        self.SetAlpha(SPXic(self))
        # self.SetAlpha(CCModel(self))
        # self.SetAlpha(SPXButterfly(self))
        # self.SetAlpha(SPXCondor(self))

        self.SetPortfolioConstruction(OptionsPortfolioConstruction(self))

        # self.SetPortfolioConstruction(InsightWeightingPortfolioConstructionModel())
        # self.SetExecution(SpreadExecutionModel())
        self.SetExecution(SPXExecutionModel(self))
        # self.SetExecution(AutoExecutionModel(self))
        # self.SetExecution(SmartPricingExecutionModel(self))
        # self.SetExecution(ImmediateExecutionModel())

        # self.SetRiskManagement(NoStopLossModel(self))
        # self.SetRiskManagement(StopLossModel(self))
        # self.SetRiskManagement(FPLMonitorModel(self))
        self.SetRiskManagement(SPXicMonitor(self))
        # self.SetRiskManagement(CCMonitor(self))
        # self.SetRiskManagement(SPXButterflyMonitor(self))
        # self.SetRiskManagement(SPXCondorMonitor(self))

    # Initialize the security every time that a new one is added
    def OnSecuritiesChanged(self, changes):
        for security in changes.AddedSecurities:
            self.structure.CompleteSecurityInitializer(security)
        for security in changes.RemovedSecurities:
            self.structure.ClearSecurity(security)

    def OnEndOfDay(self, symbol):
        self.structure.checkOpenPositions()
        self.performance.endOfDay(symbol)

    def OnOrderEvent(self, orderEvent):
        # Start the timer
        self.executionTimer.start()

        # Log the order event
        self.logger.debug(orderEvent)

        self.performance.OnOrderEvent(orderEvent)

        HandleOrderEvents(self, orderEvent).Call()
        # Loop through all strategies
        # for strategy in self.strategies:
        #     # Call the Strategy orderEvent handler
        #     strategy.handleOrderEvent(orderEvent)

        # Stop the timer
        self.executionTimer.stop()

    def OnEndOfAlgorithm(self) -> None:
        # Convert the dictionary into a Pandas Data Frame
        # dfAllPositions = pd.DataFrame.from_dict(self.allPositions, orient = "index")
        # Convert the dataclasses into Pandas Data Frame
        dfAllPositions = pd.json_normalize(obj.asdict() for k,obj in self.allPositions.items())

        if self.showExecutionStats:
            self.Log("")
            self.Log("---------------------------------")
            self.Log("     Execution  Statistics       ")
            self.Log("---------------------------------")
            self.executionTimer.showStats()
            self.Log("")
        if self.showPerformanceStats:
            self.Log("---------------------------------")
            self.Log("     Performance Statistics       ")
            self.Log("---------------------------------")
            self.performance.show()
            self.Log("")
            self.Log("")

        if self.showTradeLog:
            self.Log("---------------------------------")
            self.Log("           Trade Log             ")
            self.Log("---------------------------------")
            self.Log("")
            if self.CSVExport:
                # Print the csv header
                self.Log(dfAllPositions.head(0).to_csv(index = False, header = True, line_terminator = " "))
                # Print the data frame to the log in csv format (one row at a time to avoid QC truncation limitation)
                for i in range(0, len(dfAllPositions.index)):
                    self.Log(dfAllPositions.iloc[[i]].to_csv(index = False, header = False, line_terminator = " "))
            else:
                self.Log(f"\n#{dfAllPositions.to_string()}")
        self.Log("")

    def lastTradingDay(self, expiry):
        # Get the trading calendar
        tradingCalendar = self.TradingCalendar
        # Find the last trading day for the given expiration date
        lastDay = list(tradingCalendar.GetDaysByType(TradingDayType.BusinessDay, expiry - timedelta(days = 20), expiry))[-1].Date
        return lastDay