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