diff --git a/looptrader/__main__.py b/looptrader/__main__.py index 55a4ace..139f8ad 100644 --- a/looptrader/__main__.py +++ b/looptrader/__main__.py @@ -21,21 +21,63 @@ vgshstrat = LongSharesStrategy( strategy_name="VGSH Core", underlying="VGSH", portfolio_allocation_percent=0.9 ) + cspstrat = SingleByDeltaStrategy( strategy_name="Puts", put_or_call="PUT", target_delta=0.07, min_delta=0.03, - profit_target_percent=0.7, + profit_target_percent=(0.95, 0.04, 0.70), + max_loss_calc_percent=dict({1: 0.2, 2: 0.2}), + ) + + tsla_strat = SingleByDeltaStrategy( + strategy_name="TSLA Puts", + put_or_call="PUT", + underlying="TSLA", + target_delta=0.05, + min_delta=0.03, + minimum_dte=3, + maximum_dte=7, + portfolio_allocation_percent=0.05, + profit_target_percent=0.95, + max_loss_calc_percent=0.2, + ) + + amzn_strat = SingleByDeltaStrategy( + strategy_name="AMZN Puts", + put_or_call="PUT", + underlying="AMZN", + target_delta=0.05, + min_delta=0.03, + minimum_dte=3, + maximum_dte=7, + portfolio_allocation_percent=0.05, + profit_target_percent=0.95, + max_loss_calc_percent=0.2, ) + nakedcalls = SingleByDeltaStrategy( strategy_name="Calls", put_or_call="CALL", target_delta=0.03, min_delta=0.01, profit_target_percent=0.83, + portfolio_allocation_percent=1.5, + offset_sold_positions=True, + ) + + spreadstrat = SpreadsByDeltaStrategy(strategy_name="spreads", targetdelta=-0.07) + + ira_puts = SingleByDeltaStrategy( + strategy_name="ira_Puts", + put_or_call="PUT", + target_delta=0.07, + min_delta=0.03, + profit_target_percent=(0.95, 0.04, 0.70), + offset_sold_positions=True, + portfolio_allocation_percent=2.0, ) - spreadstrat = SpreadsByDeltaStrategy(strategy_name="spreads") # Create our brokers individualbroker = TdaBroker(id="individual") @@ -51,6 +93,8 @@ bot = Bot( brokerstrategy={ spreadstrat: irabroker, + tsla_strat: individualbroker, + # amzn_strat: individualbroker, cspstrat: individualbroker, nakedcalls: individualbroker, vgshstrat: individualbroker, diff --git a/looptrader/basetypes/Broker/tdaBroker.py b/looptrader/basetypes/Broker/tdaBroker.py index 0732b32..15277fc 100644 --- a/looptrader/basetypes/Broker/tdaBroker.py +++ b/looptrader/basetypes/Broker/tdaBroker.py @@ -31,6 +31,7 @@ from td.option_chain import OptionChain logger = logging.getLogger("autotrader") +DT_REGEX = "%Y-%m-%dT%H:%M:%S%z" @attr.s(auto_attribs=True) @@ -73,7 +74,7 @@ def __attrs_post_init__(self): return # If no match, raise exception - raise Exception("No credentials found in config.yaml") + raise RuntimeError("No credentials found in config.yaml") ######## # Read # @@ -99,10 +100,9 @@ def get_account( ) except Exception: logger.exception( - "Failed to get Account {}. Attempt #{}".format( - self.account_number, attempt - ) + f"Failed to get Account {self.account_number}. Attempt #{attempt}" ) + if attempt == self.maxretries - 1: return None @@ -127,14 +127,12 @@ def get_order( account=self.account_number, order_id=str(request.orderid) ) except Exception: - logger.exception( - "Failed to read order {}.".format(str(request.orderid)) - ) + logger.exception(f"Failed to read order {str(request.orderid)}.") if attempt == self.maxretries - 1: return None if order is None: - return None + raise ValueError("No Order to Translate") response = baseRR.GetOrderResponseMessage() @@ -160,20 +158,18 @@ def get_option_chain( optionchainobj.query_parameters = optionchainrequest if not optionchainobj.validate_chain(): - logger.exception("Chain Validation Failed. {}".format(optionchainobj)) + logger.exception(f"Chain Validation Failed. {optionchainobj}") return None for attempt in range(self.maxretries): try: optionschain = self.getsession().get_options_chain(optionchainrequest) - if optionschain.status == "FAILED": + if optionschain["status"] == "FAILED": raise BaseException("Option Chain Status Response = FAILED") except Exception: - logger.exception( - "Failed to get Options Chain. Attempt #{}".format(attempt) - ) + logger.exception(f"Failed to get Options Chain. Attempt #{attempt}") if attempt == self.maxretries - 1: return None @@ -205,9 +201,7 @@ def get_quote( quotes = self.getsession().get_quotes(request.instruments) break except Exception: - logger.exception( - "Failed to get quotes. Attempt #{}".format(attempt), - ) + logger.exception(f"Failed to get quotes. Attempt #{attempt}") if attempt == self.maxretries - 1: return None @@ -251,10 +245,9 @@ def get_market_hours( break except Exception: logger.exception( - "Failed to get market hours for {} on {}. Attempt #{}".format( - markets, request.datetime, attempt - ), + f"Failed to get market hours for {markets} on {request.datetime}. Attempt #{attempt}" ) + if attempt == self.maxretries - 1: return None @@ -368,11 +361,9 @@ def build_market_hours_response( response = baseRR.GetMarketHoursResponseMessage() startdt = dtime.datetime.strptime( - str(dict(markethours[0]).get("start")), "%Y-%m-%dT%H:%M:%S%z" - ) - enddt = dtime.datetime.strptime( - str(dict(markethours[0]).get("end")), "%Y-%m-%dT%H:%M:%S%z" + str(dict(markethours[0]).get("start")), DT_REGEX ) + enddt = dtime.datetime.strptime(str(dict(markethours[0]).get("end")), DT_REGEX) response.start = startdt.astimezone(dtime.timezone.utc) response.end = enddt.astimezone(dtime.timezone.utc) response.isopen = details.get("isOpen", bool) @@ -424,14 +415,14 @@ def place_order( response = baseRR.PlaceOrderResponseMessage() # Log the Order - logger.info("Your order being placed is: {} ".format(orderrequest)) + logger.info(f"Your order being placed is: {orderrequest} ") # Place the Order try: orderresponse = self.getsession().place_order( account=self.account_number, order=orderrequest ) - logger.info("Order {} Placed".format(orderresponse["order_id"])) + logger.info(f'Order {orderresponse["order_id"]} Placed') except Exception: logger.exception("Failed to place order.") return None @@ -469,7 +460,7 @@ def cancel_order( order_id=str(request.orderid), ) except Exception: - logger.exception("Failed to cancel order {}.".format(str(request.orderid))) + logger.exception(f"Failed to cancel order {str(request.orderid)}.") return None response = baseRR.CancelOrderResponseMessage() @@ -489,8 +480,8 @@ def process_session_hours( ############### # Translators # ############### - @staticmethod def translate_option_chain( + self, rawoptionchain: dict, ) -> list[baseRR.GetOptionChainResponseMessage.ExpirationDate]: """Transforms a TDA option chain dictionary into a LoopTrader option chain""" @@ -511,28 +502,9 @@ def translate_option_chain( for details in strikes.values(): detail: dict for detail in details: - if detail.get("settlementType", str) == "P": - strikeresponse = ( - baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike() - ) - strikeresponse.strike = detail.get("strikePrice", float) - strikeresponse.multiplier = detail.get("multiplier", float) - strikeresponse.bid = detail.get("bid", float) - strikeresponse.ask = detail.get("ask", float) - strikeresponse.delta = detail.get("delta", float) - strikeresponse.gamma = detail.get("gamma", float) - strikeresponse.theta = detail.get("theta", float) - strikeresponse.vega = detail.get("vega", float) - strikeresponse.rho = detail.get("rho", float) - strikeresponse.symbol = detail.get("symbol", str) - strikeresponse.description = detail.get("description", str) - strikeresponse.putcall = detail.get("putCall", str) - strikeresponse.settlementtype = detail.get( - "settlementType", str - ) - strikeresponse.expirationtype = detail.get( - "expirationType", str - ) + settlement_type = detail.get("settlementType") + if settlement_type in ["P", " "]: + strikeresponse = self.Build_Option_Chain_Strike(detail) expiry.strikes[ detail.get("strikePrice", float) @@ -542,21 +514,41 @@ def translate_option_chain( return response + @staticmethod + def Build_Option_Chain_Strike(detail: dict): + strikeresponse = baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike() + strikeresponse.strike = detail.get("strikePrice", float) + strikeresponse.multiplier = detail.get("multiplier", float) + strikeresponse.bid = detail.get("bid", float) + strikeresponse.ask = detail.get("ask", float) + strikeresponse.delta = detail.get("delta", float) + strikeresponse.gamma = detail.get("gamma", float) + strikeresponse.theta = detail.get("theta", float) + strikeresponse.vega = detail.get("vega", float) + strikeresponse.rho = detail.get("rho", float) + strikeresponse.symbol = detail.get("symbol", str) + strikeresponse.description = detail.get("description", str) + strikeresponse.putcall = detail.get("putCall", str) + strikeresponse.settlementtype = detail.get("settlementType", str) + strikeresponse.expirationtype = detail.get("expirationType", str) + + return strikeresponse + def translate_account_order_activity( - self, orderActivity: dict + self, order_activity: dict ) -> baseModels.OrderActivity: """Transforms a TDA order activity dictionary into a LoopTrader order leg""" account_order_activity = baseModels.OrderActivity() account_order_activity.id = None - account_order_activity.activity_type = orderActivity.get("activityType", "") - account_order_activity.execution_type = orderActivity.get("executionType", "") - account_order_activity.quantity = orderActivity.get("quantity", 0) - account_order_activity.order_remaining_quantity = orderActivity.get( + account_order_activity.activity_type = order_activity.get("activityType", "") + account_order_activity.execution_type = order_activity.get("executionType", "") + account_order_activity.quantity = order_activity.get("quantity", 0) + account_order_activity.order_remaining_quantity = order_activity.get( "orderRemainingQuantity", 0 ) - legs = orderActivity.get("executionLegs") + legs = order_activity.get("executionLegs") if legs is not None: for leg in legs: # Build Leg @@ -577,7 +569,7 @@ def translate_account_order_execution_leg(leg: dict) -> baseModels.ExecutionLeg: account_order_activity.price = leg.get("price", 0.0) account_order_activity.quantity = leg.get("quantity", 0) account_order_activity.time = dtime.datetime.strptime( - leg.get("time", dtime.datetime), "%Y-%m-%dT%H:%M:%S%z" + leg.get("time", dtime.datetime), DT_REGEX ) return account_order_activity @@ -613,31 +605,10 @@ def translate_account_order_leg(leg: dict) -> baseModels.OrderLeg: def translate_account_order(self, order: dict) -> baseModels.Order: """Transforms a TDA order dictionary into a LoopTrader order""" - accountorder = baseModels.Order() - accountorder.order_strategy_type = order.get("complexOrderStrategyType", "") - accountorder.order_type = order.get("orderType", "") - accountorder.remaining_quantity = order.get("remainingQuantity", 0) - accountorder.requested_destination = order.get("requestedDestination", "") - accountorder.session = order.get("session", "") - accountorder.duration = order.get("duration", "") - accountorder.quantity = order.get("quantity", 0) - accountorder.filled_quantity = order.get("filledQuantity", 0) - accountorder.price = order.get("price", 0.0) - accountorder.order_id = order.get("orderId", 0) - accountorder.status = order.get("status", "") - accountorder.entered_time = dtime.datetime.strptime( - order.get("enteredTime", dtime.datetime), "%Y-%m-%dT%H:%M:%S%z" - ) + if order is None: + raise ValueError("No Order Passed In") - close = order.get("closeTime") - if close is not None: - accountorder.close_time = dtime.datetime.strptime( - close, "%Y-%m-%dT%H:%M:%S%z" - ) - accountorder.account_id = order.get("accountId", 0) - accountorder.cancelable = order.get("cancelable", False) - accountorder.editable = order.get("editable", False) - accountorder.legs = [] + accountorder = self.translate_base_account_order(order) legs = order.get("orderLegCollection") if legs is not None: @@ -660,6 +631,36 @@ def translate_account_order(self, order: dict) -> baseModels.Order: return accountorder + @staticmethod + def translate_base_account_order(order) -> baseModels.Order: + if order is None: + raise ValueError("No Order to Translate") + + accountorder = baseModels.Order() + accountorder.order_strategy_type = order.get("complexOrderStrategyType", "") + accountorder.order_type = order.get("orderType", "") + accountorder.remaining_quantity = order.get("remainingQuantity", 0) + accountorder.requested_destination = order.get("requestedDestination", "") + accountorder.session = order.get("session", "") + accountorder.duration = order.get("duration", "") + accountorder.quantity = order.get("quantity", 0) + accountorder.filled_quantity = order.get("filledQuantity", 0) + accountorder.price = order.get("price", 0.0) + accountorder.order_id = order.get("orderId", 0) + accountorder.status = order.get("status", "") + accountorder.entered_time = dtime.datetime.strptime( + order.get("enteredTime", dtime.datetime), DT_REGEX + ) + + close = order.get("closeTime") + if close is not None: + accountorder.close_time = dtime.datetime.strptime(close, DT_REGEX) + accountorder.account_id = order.get("accountId", 0) + accountorder.cancelable = order.get("cancelable", False) + accountorder.editable = order.get("editable", False) + accountorder.legs = [] + return accountorder + @staticmethod def translate_account_position(position: dict): """Transforms a TDA position dictionary into a LoopTrader position""" @@ -681,30 +682,47 @@ def translate_account_position(position: dict): instrument = position.get("instrument", dict) if instrument is not None: - desc = instrument.get("description") + TdaBroker.translate_account_position_instrument(accountposition, instrument) - if desc is not None: - match = re.search( - r"([A-Z]{1}[a-z]{2} \d{2} \d{4})", instrument.get("description") - ) - if match is not None: - accountposition.expirationdate = dtime.datetime.strptime( - match.group(), "%b %d %Y" - ) + return accountposition - accountposition.assettype = instrument.get("assetType", str) - accountposition.description = instrument.get("description", str) - accountposition.putcall = instrument.get("putCall", str) - accountposition.symbol = instrument.get("symbol", str) - accountposition.underlyingsymbol = instrument.get("underlyingSymbol", str) + @staticmethod + def translate_account_position_instrument( + accountposition: baseRR.AccountPosition, instrument: dict + ): + """Translates an instrument into an account position + + Args: + accountposition (baseRR.AccountPosition): Account Position to build + instrument (baseRR.Instrument): Instrument to translate + """ + # Get the symbol + symbol = instrument.get("symbol", str) + + accountposition.assettype = instrument.get("assetType", str) + + # If we have a symbol, try to extract an expiration date + if symbol is not None: + # Set the symbol + accountposition.symbol = symbol + + # Get our regex match + exp_match = re.search(r"(\d{6})(?=[PC])", symbol) + + # If we have a match, try to StrpTime + if exp_match is not None: + accountposition.expirationdate = dtime.datetime.strptime( + exp_match.group(), "%m%d%y" + ) - strikeprice = re.search(r"(?<=[PC])\d\w+", instrument.get("symbol", str)) + strike_match = re.search(r"(?<=[PC])\d\w+", symbol) - if strikeprice is None and accountposition.assettype == "OPTION": - logger.error( - "No strike price found for {}".format(instrument.get("symbol", str)) - ) - elif strikeprice is not None: - accountposition.strikeprice = float(strikeprice.group()) + if strike_match is not None: + accountposition.strikeprice = float(strike_match.group()) + elif accountposition.assettype == "OPTION": + logger.error(f"No strike price found for {symbol}") - return accountposition + # Map the other fields + accountposition.description = instrument.get("description", str) + accountposition.putcall = instrument.get("putCall", str) + accountposition.underlyingsymbol = instrument.get("underlyingSymbol", str) diff --git a/looptrader/basetypes/Database/abstractDatabase.py b/looptrader/basetypes/Database/abstractDatabase.py index 779655a..49ce311 100644 --- a/looptrader/basetypes/Database/abstractDatabase.py +++ b/looptrader/basetypes/Database/abstractDatabase.py @@ -47,3 +47,11 @@ def read_active_orders( raise NotImplementedError( "Each database must implement the 'read_open_orders' method." ) + + @abc.abstractmethod + def read_offset_legs_by_expiration( + self, request: baseRR.ReadOffsetLegsByExpirationRequest + ) -> Union[baseRR.ReadOffsetLegsByExpirationResponse, None]: + raise NotImplementedError( + "Each database must implement the 'read_offset_legs_by_expiration' method." + ) diff --git a/looptrader/basetypes/Database/ormDatabase.py b/looptrader/basetypes/Database/ormDatabase.py index b55ae32..b208805 100644 --- a/looptrader/basetypes/Database/ormDatabase.py +++ b/looptrader/basetypes/Database/ormDatabase.py @@ -35,7 +35,7 @@ class ormDatabase(Database): ) def __attrs_post_init__(self): - self.connection_string = "sqlite:///" + self.db_filename + self.connection_string = f"sqlite:///{self.db_filename}" self.pre_flight_db_check() ################## @@ -336,6 +336,43 @@ def read_first_strategy_by_name( return response + def read_offset_legs_by_expiration( + self, request: baseRR.ReadOffsetLegsByExpirationRequest + ) -> baseRR.ReadOffsetLegsByExpirationResponse: + # Setup DB Session + engine = create_engine(self.connection_string) + Base.metadata.bind = engine + DBSession = sessionmaker(bind=engine) + session = DBSession(expire_on_commit=False) + + # Build Response + response = baseRR.ReadOffsetLegsByExpirationResponse() + + try: + result = ( + session.query(baseModels.OrderLeg) + .join(baseModels.Order) + .filter(baseModels.Order.id == baseModels.OrderLeg.order_id) + .filter(baseModels.Order.strategy_id == request.strategy_id) + .filter(baseModels.Order.status == "FILLED") + .filter(baseModels.OrderLeg.expiration_date == request.expiration) + .filter(baseModels.OrderLeg.put_call == request.put_or_call) + .filter(baseModels.OrderLeg.instruction == "BUY_TO_OPEN") + .all() + ) + + session.commit() + + response.offset_legs = result + except Exception as e: + print(e) + session.rollback() + finally: + session.close() + engine.dispose() + + return response + ########### # Updates # ########### diff --git a/looptrader/basetypes/Mediator/abstractMediator.py b/looptrader/basetypes/Mediator/abstractMediator.py index 17526ef..c3b97b7 100644 --- a/looptrader/basetypes/Mediator/abstractMediator.py +++ b/looptrader/basetypes/Mediator/abstractMediator.py @@ -131,3 +131,10 @@ def read_active_orders( raise NotImplementedError( "Each mediator must implement the 'read_open_orders' method." ) + + def read_offset_legs_by_expiration( + self, request: baseRR.ReadOffsetLegsByExpirationRequest + ) -> Union[baseRR.ReadOffsetLegsByExpirationResponse, None]: + raise NotImplementedError( + "Each mediator must implement the 'read_offset_legs_by_expiration' method." + ) diff --git a/looptrader/basetypes/Mediator/botMediator.py b/looptrader/basetypes/Mediator/botMediator.py index 2307247..de7a801 100644 --- a/looptrader/basetypes/Mediator/botMediator.py +++ b/looptrader/basetypes/Mediator/botMediator.py @@ -223,11 +223,14 @@ def get_broker(self, strategy_id: int) -> Union[Broker, None]: Returns: Broker: Associated Broker object """ - for strategy, broker in self.brokerstrategy.items(): - if strategy.strategy_id == strategy_id: - return broker - - return None + return next( + ( + broker + for strategy, broker in self.brokerstrategy.items() + if strategy.strategy_id == strategy_id + ), + None, + ) def get_all_strategies(self) -> list[str]: strategies = list[str]() @@ -256,3 +259,8 @@ def read_active_orders( self, request: baseRR.ReadOpenDatabaseOrdersRequest ) -> Union[baseRR.ReadOpenDatabaseOrdersResponse, None]: return self.database.read_active_orders(request) + + def read_offset_legs_by_expiration( + self, request: baseRR.ReadOffsetLegsByExpirationRequest + ) -> Union[baseRR.ReadOffsetLegsByExpirationResponse, None]: + return self.database.read_offset_legs_by_expiration(request) diff --git a/looptrader/basetypes/Mediator/reqRespTypes.py b/looptrader/basetypes/Mediator/reqRespTypes.py index 4f2a9aa..fe3cbde 100644 --- a/looptrader/basetypes/Mediator/reqRespTypes.py +++ b/looptrader/basetypes/Mediator/reqRespTypes.py @@ -334,6 +334,20 @@ class ReadOpenDatabaseOrdersResponse: ) +@attr.s(auto_attribs=True) +class ReadOffsetLegsByExpirationRequest: + strategy_id: int = attr.ib(validator=attr.validators.instance_of(int)) + put_or_call: str = attr.ib(validator=attr.validators.instance_of(str)) + expiration: datetime = attr.ib(validator=attr.validators.instance_of(datetime)) + + +@attr.s(auto_attribs=True, init=False) +class ReadOffsetLegsByExpirationResponse: + offset_legs: list[base.OrderLeg] = attr.ib( + validator=attr.validators.instance_of(list[base.OrderLeg]) + ) + + @attr.s(auto_attribs=True) class GetQuoteRequestMessage: strategy_id: int = attr.ib(validator=attr.validators.instance_of(int)) diff --git a/looptrader/basetypes/Notifier/telegramnotifier.py b/looptrader/basetypes/Notifier/telegramnotifier.py index ecb0687..7391947 100644 --- a/looptrader/basetypes/Notifier/telegramnotifier.py +++ b/looptrader/basetypes/Notifier/telegramnotifier.py @@ -204,10 +204,12 @@ def error(self, update, context: CallbackContext) -> None: """Method to handle errors occurring in the dispatcher""" logger.warning('Update "%s" caused error "%s"', update, context.error) + message = "" if update is None else update.message + try: self.reply_text( r"An error occured, check the logs.", - update.message, + message, None, ParseMode.HTML, ) @@ -403,7 +405,7 @@ def reply_text( parsemode: Union[DefaultValue[str], str, None], ): """Wrapper method to send reply texts""" - if message is None: + if message == "" or message is None: return try: diff --git a/looptrader/basetypes/Strategy/helpers.py b/looptrader/basetypes/Strategy/helpers.py index fc8f9d5..5840f4e 100644 --- a/looptrader/basetypes/Strategy/helpers.py +++ b/looptrader/basetypes/Strategy/helpers.py @@ -1,6 +1,7 @@ import logging import logging.config import math +import re from typing import Union from urllib.request import urlopen from xml.etree.ElementTree import parse @@ -47,6 +48,20 @@ def truncate(number: float, digits: int) -> float: return math.trunc(stepper * number) / stepper +def get_strike_from_symbol(symbol: str) -> Union[None, float]: + """Returns the strike for an option, based on the symbol string provided + + Args: + symbol (str): Symbol String + + Returns: + float: Strike + """ + match = re.search(r"([0-9])+$", symbol) + + return float(match.group()) if match is not None else None + + ############################## ### Notification Functions ### ############################## diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index 1e07374..b48e7b1 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -2,6 +2,7 @@ import logging import logging.config import math +import re import time from typing import Union @@ -42,11 +43,10 @@ class SingleByDeltaStrategy(Strategy, Component): ) minimum_dte: int = attr.ib(default=1, validator=attr.validators.instance_of(int)) maximum_dte: int = attr.ib(default=4, validator=attr.validators.instance_of(int)) - profit_target_percent: float = attr.ib( - default=0.7, validator=attr.validators.instance_of(float) - ) - max_loss_calc_percent: float = attr.ib( - default=0.2, validator=attr.validators.instance_of(float) + profit_target_percent: Union[float, tuple] = attr.ib(default=0.7) + max_loss_calc_percent: Union[float, dict[int, float]] = attr.ib(default=0.2) + max_loss_calc_method: str = attr.ib( + default="STRIKE", validator=attr.validators.in_(["STRIKE", "SPREAD"]) ) opening_order_loop_seconds: int = attr.ib( default=20, validator=attr.validators.instance_of(int) @@ -56,15 +56,12 @@ class SingleByDeltaStrategy(Strategy, Component): default=dt.datetime.now().astimezone(dt.timezone.utc), validator=attr.validators.instance_of(dt.datetime), ) - minutes_after_open_delay: int = attr.ib( - default=3, validator=attr.validators.instance_of(int) - ) early_market_offset: dt.timedelta = attr.ib( - default=dt.timedelta(minutes=5), + default=dt.timedelta(minutes=15), validator=attr.validators.instance_of(dt.timedelta), ) late_market_offset: dt.timedelta = attr.ib( - default=dt.timedelta(minutes=10), + default=dt.timedelta(minutes=5), validator=attr.validators.instance_of(dt.timedelta), ) after_hours_offset: dt.timedelta = attr.ib( @@ -74,6 +71,9 @@ class SingleByDeltaStrategy(Strategy, Component): use_vollib_for_greeks: bool = attr.ib( default=True, validator=attr.validators.instance_of(bool) ) + offset_sold_positions: bool = attr.ib( + default=False, validator=attr.validators.instance_of(bool) + ) # Core Strategy Process def process_strategy(self): @@ -88,7 +88,7 @@ def process_strategy(self): return # Get Market Hours - market_hours = self.get_next_market_hours(date=now) + market_hours = self.get_next_market_hours() if market_hours is None: return @@ -128,15 +128,12 @@ def process_strategy(self): # Process After-Market self.process_after_market() - return - ############################### ### Closed Market Functions ### ############################### def process_closed_market(self, market_open: dt.datetime): # Sleep until market opens self.sleep_until_market_open(market_open) - return ############################ ### Pre-Market Functions ### @@ -144,7 +141,6 @@ def process_closed_market(self, market_open: dt.datetime): def process_pre_market(self, market_open: dt.datetime): # Sleep until market opens self.sleep_until_market_open(market_open) - return ############################ ### Early Core Functions ### @@ -163,7 +159,7 @@ def process_core_market(self): # Logger logger.debug( - f"Strategy {self.strategy_name} Has {'' if has_open_orders else 'No '}Open Orders" + f"Strategy {self.strategy_name} Has {'' if has_open_orders else 'No '}Open Order(s)" ) # If no open orders, open a new one. @@ -180,7 +176,7 @@ def process_late_core_market(self): # Logger logger.debug( - f"Strategy {self.strategy_name} Has {'' if has_open_orders else 'No '}Open Orders" + f"Strategy {self.strategy_name} Has {'' if has_open_orders else 'No '}Open Order(s)" ) # If no open orders, open a new one. @@ -193,7 +189,7 @@ def process_late_core_market(self): # Check if the position expires today if order.legs[0].expiration_date == dt.date.today(): # Offset - self.place_offsetting_order_loop(order.quantity) + self.place_offsetting_order_loop(order) # Open a new position self.place_new_orders_loop() @@ -224,7 +220,6 @@ def process_after_market(self): market = self.get_next_market_hours() self.sleep_until_market_open(market.start) - return ###################### ### Order Builders ### @@ -235,17 +230,20 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: # Get account balance account = self.mediator.get_account( - baseRR.GetAccountRequestMessage(self.strategy_id, False, True) + baseRR.GetAccountRequestMessage(self.strategy_id, False, False) ) - if account is None or not hasattr(account, "positions"): + if account is None: logger.error("Failed to get Account") return None - # Get option chain - min_date = dt.date.today() + dt.timedelta(days=self.minimum_dte) - max_date = dt.date.today() + dt.timedelta(days=self.maximum_dte) - chainrequest = self.build_option_chain_request(min_date, max_date) + # Get our available BP + # availbp = self.calculate_strategy_buying_power( + # account.currentbalances.liquidationvalue + # ) + + # Get default option chain + chainrequest = self.build_option_chain_request() chain = self.mediator.get_option_chain(chainrequest) @@ -253,46 +251,61 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: logger.error("Failed to get Option Chain.") return None - # Should we even try? - availbp = self.calculate_actual_buying_power(account) - # Find next expiration - if self.put_or_call == "PUT": - expiration = self.get_next_expiration(chain.putexpdatemap) - if self.put_or_call == "CALL": - expiration = self.get_next_expiration(chain.callexpdatemap) + expiration = self.get_next_expiration(chain) # If no valid expirations, exit. if expiration is None: return None # Find best strike to trade - strike = self.get_best_strike( + ( + best_strike, + best_offset_strike, + best_premium, + best_quantity, + best_offset_qty, + ) = self.get_best_strike_and_quantity_v2( expiration.strikes, - availbp, account.currentbalances.liquidationvalue, expiration.daystoexpiration, chain.underlyinglastprice, - chain.volatility, + expiration.expirationdate, ) + # strike, quantity = self.get_best_strike_and_quantity( + # expiration.strikes, + # availbp, + # account.currentbalances.liquidationvalue, + # expiration.daystoexpiration, + # chain.underlyinglastprice, + # ) # If no valid strikes, exit. - if strike is None: + # if strike is None: + # return None + if best_strike is None or best_quantity == 0: return None - # Calculate Quantity - qty = self.calculate_order_quantity( - strike.strike, availbp, account.currentbalances.liquidationvalue - ) + # offset_strike, offset_qty = self.get_offset_strike_and_quantity( + # account, expiration, strike, quantity + # ) - # Calculate price - formattedprice = helpers.format_order_price((strike.bid + strike.ask) / 2) + # # Return Order + # return self.build_opening_order_request( + # strike, quantity, offset_strike, offset_qty + # ) # Return Order - return self.build_opening_order_request(strike, qty, formattedprice) + return self.build_opening_order_request_v2( + best_strike, + best_quantity, + best_premium, + best_offset_strike, + best_offset_qty, + ) def build_offsetting_order( - self, qty: int + self, order: baseModels.Order ) -> Union[baseRR.PlaceOrderRequestMessage, None]: """Trading Logic for building Offsetting Order Request Messages""" logger.debug("build_offsetting_order") @@ -308,101 +321,247 @@ def build_offsetting_order( # Find next expiration if self.put_or_call == "CALL": expiration = chain.callexpdatemap[0] - elif self.put_or_call == "PUT": + else: expiration = chain.putexpdatemap[0] + # Get account balance + account = self.mediator.get_account( + baseRR.GetAccountRequestMessage(self.strategy_id, False, True) + ) + + if account is None or not hasattr(account, "positions"): + logger.error("Failed to get Account") + return None + + # Get Short Strike + short_strike = helpers.get_strike_from_symbol(order.legs[0].symbol) + + if short_strike is None: + return None + # Find best strike to trade - strike = self.get_offsetting_strike(expiration.strikes) + strike = self.get_offsetting_strike( + expiration.strikes, account, order.quantity, short_strike + ) if strike is None: logger.error("Failed to get Offsetting Strike.") return None # Return Order - return self.build_opening_order_request( - strike, qty, strike.ask, offsetting=True + return self.build_opening_order_request(strike, order.quantity, offsetting=True) + + def build_closing_order( + self, original_order: baseModels.Order + ) -> Union[None, baseRR.PlaceOrderRequestMessage]: + """Builds a closing order request message for a given position.""" + # Build base order + order_request = self.build_base_order_request_message(is_closing=True) + + # Build and append new legs + for leg in original_order.legs: + instruction = self.get_closing_order_instruction(leg.instruction) + + if instruction is None: + break + + # Get the Strike + original_strike = helpers.get_strike_from_symbol(leg.symbol) + + if original_strike is None: + break + + # Build the new leg and append it + new_leg = self.build_leg( + leg.symbol, leg.description, leg.quantity, instruction, opening=False + ) + order_request.order.legs.append(new_leg) + + if original_strike is None: + return None + + pt = self.calculate_profit_target(original_strike) + + if pt is None: + return None + + # Set and format the closing price + order_request.order.price = helpers.format_order_price( + original_order.price * (1 - pt) ) - def build_opening_order_request( + # Return request + return order_request + + def build_opening_order_request_v2( self, strike: baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, - qty: int, - price: float, + order_qty: int, + premium: float, + offset_strike: Union[ + baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, None + ] = None, + offset_qty: Union[int, None] = None, offsetting: bool = False, - ) -> baseRR.PlaceOrderRequestMessage: # sourcery skip: class-extract-method - """Builds an order request to open a new postion + ) -> Union[baseRR.PlaceOrderRequestMessage, None]: - Args: - strike (baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike): The strike to trade - qty (int): The number of contracts - price (float): Contract Price + # If no valid qty, exit. + if order_qty is None or order_qty <= 0: + return None - Returns: - baseRR.PlaceOrderRequestMessage: Order request message - """ - # Build Leg - leg = baseModels.OrderLeg() - leg.symbol = strike.symbol - leg.asset_type = "OPTION" - leg.quantity = qty - leg.position_effect = "OPENING" + # Determine how many legs are in the order + single_leg = offset_strike is None or offset_qty == 0 + + # Build base order request + order_request = self.build_base_order_request_message(is_single=single_leg) + # Build the first leg + first_leg = self.build_leg( + strike.symbol, + strike.description, + order_qty, + "BUY" if offsetting else self.buy_or_sell, + True, + ) + # Append the leg + order_request.order.legs.append(first_leg) + + # If we are building an offse... if ( - offsetting - and self.buy_or_sell == "SELL" - or not offsetting - and self.buy_or_sell != "SELL" + not single_leg + and offset_strike is not None + and offset_qty is not None + and offset_qty > 0 ): - leg.instruction = "BUY_TO_OPEN" - else: - leg.instruction = "SELL_TO_OPEN" + # Build the offset leg + long_leg = self.build_leg( + offset_strike.symbol, offset_strike.description, offset_qty, "BUY", True + ) + # Append the leg + order_request.order.legs.append(long_leg) - # Build Order + # Format the price + order_request.order.price = helpers.format_order_price(premium) + + # Return the request message + return order_request + + def build_opening_order_request( + self, + strike: baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, + qty: int, + offset_strike: Union[ + baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, None + ] = None, + offset_qty: int = 0, + offsetting: bool = False, + ) -> Union[baseRR.PlaceOrderRequestMessage, None]: + + # If no valid qty, exit. + if qty is None or qty <= 0: + return None + + # Determine how many legs are in the order + single_leg = offset_strike is None or offset_qty <= 0 + + # Quantity will be the smallest quantity > 0 + order_qty = min(qty, offset_qty) if offset_qty > 0 else qty + + # Build base order request + order_request = self.build_base_order_request_message(is_single=single_leg) + + # Build the first leg + first_leg = self.build_leg( + strike.symbol, + strike.description, + order_qty, + "BUY" if offsetting else self.buy_or_sell, + True, + ) + # Append the leg + order_request.order.legs.append(first_leg) + + # Calculate price + price = (strike.bid + strike.ask) / 2 + + # If we are building an offse... + if not single_leg and offset_strike is not None: + # Build the offset leg + long_leg = self.build_leg( + offset_strike.symbol, offset_strike.description, order_qty, "BUY", True + ) + # Append the leg + order_request.order.legs.append(long_leg) + + # Recalculate the price + price = price - (offset_strike.bid + offset_strike.ask) / 2 + + # Format the price + order_request.order.price = helpers.format_order_price(price) + + # Return the request message + return order_request + + def build_base_order_request_message( + self, is_closing: bool = False, is_single: bool = True + ) -> baseRR.PlaceOrderRequestMessage: orderrequest = baseRR.PlaceOrderRequestMessage() orderrequest.order = baseModels.Order() orderrequest.order.strategy_id = self.strategy_id orderrequest.order.order_strategy_type = "SINGLE" orderrequest.order.duration = "GOOD_TILL_CANCEL" - orderrequest.order.order_type = "LIMIT" + + if self.offset_sold_positions is False or is_single: + orderrequest.order.order_type = "LIMIT" + elif ( + is_closing + and self.buy_or_sell == "SELL" + or not is_closing + and self.buy_or_sell != "SELL" + ): + orderrequest.order.order_type = "LIMIT" + else: + orderrequest.order.order_type = "NET_CREDIT" + orderrequest.order.session = "NORMAL" - orderrequest.order.price = price orderrequest.order.legs = list[baseModels.OrderLeg]() - orderrequest.order.legs.append(leg) return orderrequest - def new_build_closing_order( - self, original_order: baseModels.Order - ) -> baseRR.PlaceOrderRequestMessage: - """Builds a closing order request message for a given position.""" + def build_leg( + self, + symbol: str, + description: str, + quantity: int, + buy_or_sell: str, + opening: bool, + ) -> baseModels.OrderLeg: leg = baseModels.OrderLeg() - leg.symbol = original_order.legs[0].symbol + leg.symbol = symbol + leg.description = description leg.asset_type = "OPTION" - leg.quantity = original_order.legs[0].quantity - leg.position_effect = "CLOSING" - - if original_order.legs[0].instruction == "SELL_TO_OPEN": - leg.instruction = "BUY_TO_CLOSE" + leg.quantity = quantity + leg.position_effect = "OPENING" if opening else "CLOSING" + + # Determine Instructions + if buy_or_sell == "SELL" and opening: + instruction = "SELL_TO_OPEN" + elif buy_or_sell == "BUY" and opening: + instruction = "BUY_TO_OPEN" + elif buy_or_sell == "BUY": + instruction = "BUY_TO_CLOSE" else: - leg.instruction = "SELL_TO_CLOSE" + instruction = "SELL_TO_CLOSE" - orderrequest = baseRR.PlaceOrderRequestMessage() - orderrequest.order = baseModels.Order() - orderrequest.order.strategy_id = self.strategy_id - orderrequest.order.order_strategy_type = "SINGLE" - orderrequest.order.duration = "GOOD_TILL_CANCEL" - orderrequest.order.order_type = "LIMIT" - orderrequest.order.session = "NORMAL" - orderrequest.order.price = helpers.truncate( - helpers.format_order_price( - original_order.price * (1 - float(self.profit_target_percent)) - ), - 2, - ) - orderrequest.order.legs = list[baseModels.OrderLeg]() - orderrequest.order.legs.append(leg) + leg.instruction = instruction - return orderrequest + if leg.description is not None: + match = re.search(r"([A-Z]{1}[a-z]{2} \d{1,2} \d{4})", leg.description) + if match is not None: + re_date = dt.datetime.strptime(match.group(), "%b %d %Y") + leg.expiration_date = re_date.date() + + return leg ##################### ### Order Placers ### @@ -410,14 +569,15 @@ def new_build_closing_order( def cancel_order(self, order_id: int): # Build Request cancelorderrequest = baseRR.CancelOrderRequestMessage( - self.strategy_id, int(order_id) + self.strategy_id, order_id ) + # Send Request self.mediator.cancel_order(cancelorderrequest) - def place_offsetting_order_loop(self, qty: int) -> None: + def place_offsetting_order_loop(self, order: baseModels.Order) -> None: # Build Order - offsetting_order_request = self.build_offsetting_order(qty) + offsetting_order_request = self.build_offsetting_order(order) # If neworder is None, exit. if offsetting_order_request is None: @@ -428,9 +588,7 @@ def place_offsetting_order_loop(self, qty: int) -> None: return # Otherwise, try again - self.place_offsetting_order_loop(qty) - - return + self.place_offsetting_order_loop(order) def place_new_orders_loop(self) -> None: """Looping Logic for placing new orders""" @@ -443,15 +601,14 @@ def place_new_orders_loop(self) -> None: # Place the order and if we get a result, build the closing order. if self.place_order(new_order_request): - closing_order = self.new_build_closing_order(new_order_request.order) - self.place_order(closing_order) + closing_order = self.build_closing_order(new_order_request.order) + if closing_order is not None: + self.place_order(closing_order) return # Otherwise, try again self.place_new_orders_loop() - return - def place_order(self, orderrequest: baseRR.PlaceOrderRequestMessage) -> bool: """Method for placing new Orders and handling fills""" # Try to place the Order @@ -499,7 +656,7 @@ def place_order(self, orderrequest: baseRR.PlaceOrderRequestMessage) -> bool: # If the order isn't filled if processed_order.order.status != "FILLED": # Cancel it - self.cancel_order(new_order_result.order_id) + self.cancel_order(int(new_order_result.order_id)) # Return failure to fill order return False @@ -546,35 +703,111 @@ def get_current_orders(self) -> list[baseModels.Order]: ) latest_order = self.mediator.get_order(get_order_req) - if latest_order is not None: - latest_order.order.id = order.id + if latest_order is None: + continue + + latest_order.order.id = order.id - for leg in latest_order.order.legs: - for leg2 in order.legs: - if leg.cusip == leg2.cusip: - leg.id = leg2.id + for leg in latest_order.order.legs: + for leg2 in order.legs: + if leg.cusip == leg2.cusip: + leg.id = leg2.id + break - # Update the DB record - create_order_req = baseRR.UpdateDatabaseOrderRequest(latest_order.order) - self.mediator.update_db_order(create_order_req) + # Update the DB record + create_order_req = baseRR.UpdateDatabaseOrderRequest(latest_order.order) + self.mediator.update_db_order(create_order_req) - # If the Order's status is still open, update our flag - if latest_order.order.isActive(): - current_orders.append(latest_order.order) + # If the Order's status is still open, update our flag + if latest_order.order.isActive(): + current_orders.append(latest_order.order) return current_orders + def get_current_offsets( + self, expiration: dt.date + ) -> Union[list[baseModels.OrderLeg], None]: + """Returns the first offsetting leg found in the DB for the given expiration date. + + Args: + expiration (dt.date): Expiration date to search + + Returns: + Union[baseModels.OrderLeg,None]: The leg from the DB, if found. + """ + if expiration is None: + raise RuntimeError("No Expiration Date Provided for Offset Lookup") + + # Read DB Orders + open_offset_request = baseRR.ReadOffsetLegsByExpirationRequest( + self.strategy_id, + self.put_or_call, + dt.datetime.combine(expiration, dt.time(0, 0)), + ) + open_offsets = self.mediator.read_offset_legs_by_expiration(open_offset_request) + + if open_offsets is None or open_offsets.offset_legs == []: + logger.info("No open offset exist.") + return None + + return open_offsets.offset_legs + + def get_closing_order_instruction( + self, opening_instruction: str + ) -> Union[str, None]: + """Returns the correct instruction for a closing order leg, based on the opening leg's instruction + + Args: + opening_instruction (str): Instruction of the opening order's leg + + Returns: + Union[str, None]: The closing instruction, or None if we shouldn't close this leg. + """ + + # Return the opposite instruction, if the leg matches our strategy + if opening_instruction == "SELL_TO_OPEN" and self.buy_or_sell == "SELL": + return "BUY" + elif opening_instruction == "BUY_TO_OPEN" and self.buy_or_sell == "BUY": + return "SELL" + # If it doesn't match, return nothing, because we don't close offsetting legs, let them expire. + else: + return None + + def get_max_loss_percentage(self, dte) -> Union[float, None]: + if isinstance(self.max_loss_calc_percent, float): + return self.max_loss_calc_percent + elif isinstance(self.max_loss_calc_percent, dict): + return float( + self.max_loss_calc_percent.get(dte) + or self.max_loss_calc_percent[ + min( + self.max_loss_calc_percent.keys(), + key=lambda key: abs(key - dte), + ) + ] + ) + else: + return None + #################### ### Option Chain ### #################### def build_option_chain_request( - self, min_date, max_date + self, + min_date: Union[dt.date, None] = None, + max_date: Union[dt.date, None] = None, ) -> baseRR.GetOptionChainRequestMessage: """Builds the option chain request message. Returns: baseRR.GetOptionChainRequestMessage: Option chain request message """ + if min_date is None: + min_date = dt.date.today() + dt.timedelta(days=self.minimum_dte) + + if max_date is None: + max_date = dt.date.today() + dt.timedelta(days=self.maximum_dte) + return baseRR.GetOptionChainRequestMessage( self.strategy_id, contracttype=self.put_or_call, @@ -585,21 +818,29 @@ def build_option_chain_request( optionrange="OTM", ) - @staticmethod def get_next_expiration( - expirations: list[baseRR.GetOptionChainResponseMessage.ExpirationDate], + self, + chain: baseRR.GetOptionChainResponseMessage, ) -> Union[baseRR.GetOptionChainResponseMessage.ExpirationDate, None]: """Checks an option chain response for the next expiration date.""" logger.debug("get_next_expiration") - if expirations is None or expirations == []: - logger.error("No expirations provided.") + # Determine which expiration map to use + if self.put_or_call == "CALL": + expirations = chain.callexpdatemap + + elif self.put_or_call == "PUT": + expirations = chain.putexpdatemap + + if expirations == []: + logger.exception("Chain has no expirations.") return None # Initialize min DTE to infinity mindte = math.inf - # loop through expirations and find the minimum DTE + # Loop through expirations and find the minimum DTE + expiration: baseRR.GetOptionChainResponseMessage.ExpirationDate for expiration in expirations: dte = expiration.daystoexpiration if dte < mindte: @@ -609,32 +850,230 @@ def get_next_expiration( # Return the min expiration return minexpiration - def get_best_strike( + def get_best_strike_and_quantity_v2( self, strikes: dict[ float, baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike ], - buying_power: float, liquidation_value: float, days_to_expiration: int, underlying_last_price: float, - iv: float, + expiration_date: dt.datetime, + ) -> tuple: + """Searches Option Chain for best Strike and optionally offset strike. + + Args: + strikes (dict[ float, baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike ]): _description_ + buying_power (float): _description_ + liquidation_value (float): _description_ + days_to_expiration (int): _description_ + underlying_last_price (float): _description_ + + Returns: + tuple: _description_ + """ + logger.debug("get_best_strike") + + # Set Variables + best_strike = None + best_offset_strike = None + best_premium = float(0) + best_quantity = 0 + best_offset_qty = 0 + best_delta_dist = 1.0 + best_delta = 1.0 + + # Calculate Risk Free Rate + risk_free_rate = helpers.get_risk_free_rate() + + # Iterate through strikes + for strike, detail in strikes.items(): + offset_strike = None + + ( + offset_strike, + total_premium, + quantity, + calculated_delta, + delta_distance, + ) = self.get_strike_details( + underlying_last_price, + strike, + risk_free_rate, + days_to_expiration, + detail, + strikes, + liquidation_value, + best_delta_dist, + ) + + if self.offset_sold_positions is True and offset_strike is None: + continue + + if total_premium is None or quantity is None: + continue + + offset_qty = self.calculate_offset_leg_quantity(quantity, expiration_date) + + # If Total Premium is better or + # If our best delta is over our target delta and the current strike is closer, store this option + if total_premium > best_premium or ( + best_delta > self.target_delta and delta_distance < best_delta_dist + ): + best_strike = detail + best_offset_strike = offset_strike + best_premium = total_premium + best_quantity = quantity + best_delta_dist = delta_distance + best_delta = calculated_delta + best_offset_qty = offset_qty + + # return first strike, long strike, premium, and quantity + premium = best_premium / best_quantity if best_quantity != 0 else None + + return best_strike, best_offset_strike, premium, best_quantity, best_offset_qty + + def get_strike_details( + self, + underlying_last_price, + strike, + risk_free_rate, + days_to_expiration, + detail, + strikes, + liquidation_value, + best_delta_dist, + ) -> tuple: + # Calc Delta + calculated_delta = self.calculate_delta( + underlying_last_price, strike, risk_free_rate, days_to_expiration, detail + ) + + delta_distance = abs(abs(calculated_delta) - self.target_delta) + + # If our delta is less than the minimum, or + # If our delta is greater than the max, and not closer to the target than our best + if abs(calculated_delta) < self.min_delta or ( + abs(calculated_delta) > self.target_delta + and delta_distance > best_delta_dist + ): + return None, None, None, None, None + + # Get best long strike + offset_strike = self.get_offset_strike_v2(strike, strikes, liquidation_value) + + # Calculate the quantity + offset_strike_strike = ( + offset_strike.strike if offset_strike is not None else None + ) + quantity = self.calculate_quantity( + liquidation_value, days_to_expiration, strike, offset_strike_strike + ) + + # Calculate total premium + total_premium = self.calculate_total_premium(detail, offset_strike, quantity) + return offset_strike, total_premium, quantity, calculated_delta, delta_distance + + def get_offset_strike_v2( + self, + strike: float, + strikes: dict[ + float, baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike + ], + liquidation_value: float, + ) -> Union[None, baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike]: + # If we should immediately offset positions, decide how many we need. + if not self.offset_sold_positions: + return None + + # Get Buying Power + strat_buying_power = self.calculate_strategy_buying_power(liquidation_value) + + logger.info(f"Strat Buying Power: {strat_buying_power}") + + # Determine max spread for the available buying power. + max_strike_width = strat_buying_power / 100 + + offset_strike = self.get_offsetting_strike_v2(strikes, max_strike_width, strike) + + # If we should have an offset, but don't find one, exit. + if offset_strike is None: + logger.error("No offset strike found when expected.") + raise RuntimeError("No offset strike found when expected.") + + return offset_strike + + def get_offsetting_strike_v2( + self, + strikes: dict[ + float, baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike + ], + max_strike_width: float, + short_strike: float, ) -> Union[baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, None]: """Searches an option chain for the optimal strike.""" + logger.debug("get_offsetting_strike") + + if self.buy_or_sell == "BUY": + logger.error("Cannot buy a max-width spread.") + return None + + # Initialize values + best_mid = float("inf") + best_strike = 0.0 + + for strike, detail in strikes.items(): + # Calc mid-price + mid = (detail.bid + detail.ask) / 2 + + # Determine if our strike fits the parameters + good_strike_width = max_strike_width <= abs(short_strike - best_strike) + good_strike_position = ( + (best_strike < strike) + if (self.put_or_call == "PUT") + else (best_strike > strike) + ) + good_strike = (0.00 < mid < best_mid) or ( + (mid == best_mid) and good_strike_position and good_strike_width + ) + + # If the mid-price is lower, use it + # If we're selling a PUT and the mid price is the same, but the strike is higher, use it. + # If we're selling a CALL and the mid price is the same, but the strike is lower, use it. + if good_strike: + logger.info(f"Risk: {(abs(strike-best_strike)*detail.multiplier)}") + best_strike = strike + best_mid = mid + + # Return the strike + return strikes[best_strike] + + def get_best_strike_and_quantity( + self, + strikes: dict[ + float, baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike + ], + buying_power: float, + liquidation_value: float, + days_to_expiration: int, + underlying_last_price: float, + ) -> tuple: + """Searches an option chain for the optimal strike.""" logger.debug("get_best_strike") # Set Variables - best_premium = float(0) best_strike = None + best_premium = float(0) + best_quantity = 0 # Calculate Risk Free Rate risk_free_rate = helpers.get_risk_free_rate() # Iterate through strikes - for strike, details in strikes.items(): + for strike, detail in strikes.items(): # Sell @ Bid, Buy @ Ask - option_price = details.bid if self.buy_or_sell == "SELL" else details.ask + option_mid_price = (detail.bid + detail.ask) / 2 # Calculate Delta if self.use_vollib_for_greeks: @@ -645,51 +1084,112 @@ def get_best_strike( days_to_expiration, self.put_or_call, None, - option_price, + option_mid_price, ) else: - calculated_delta = details.delta + calculated_delta = detail.delta # Make sure strike delta is less then our target delta - if (abs(calculated_delta) <= abs(self.target_delta)) and ( - abs(calculated_delta) >= abs(self.min_delta) - ): + if abs(self.min_delta) <= abs(calculated_delta) <= abs(self.target_delta): # Calculate the total premium for the strike based on our buying power - qty = self.calculate_order_quantity( - strike, buying_power, liquidation_value + qty = self.calculate_quantity_single_strike( + strike, liquidation_value, days_to_expiration ) - total_premium = option_price * qty + total_premium = option_mid_price * qty # If the strike's premium is larger than our best premium, update it if total_premium > best_premium: best_premium = total_premium - best_strike = details + best_strike = detail + best_quantity = qty # Return the strike with the highest premium - return best_strike + return best_strike, best_quantity + + def get_offset_strike_and_quantity( + self, + account: baseRR.GetAccountResponseMessage, + expiration: baseRR.GetOptionChainResponseMessage.ExpirationDate, + strike: baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, + quantity: int, + ) -> tuple: + # If we should immediately offset positions, decide how many we need. + if not self.offset_sold_positions: + return None, 0 + + offset_qty = self.calculate_offset_leg_quantity( + quantity, expiration.expirationdate + ) + + if offset_qty <= 0: + return None, 0 + + offset_strike = self.get_offsetting_strike( + expiration.strikes, account, offset_qty, strike.strike + ) + + # If we should have an offset, but don't find one, exit. + if offset_strike is None: + logger.error("No offset strike found when expected.") + raise RuntimeError("No offset strike found when expected.") + + return offset_strike, offset_qty def get_offsetting_strike( self, strikes: dict[ float, baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike ], + account: baseRR.GetAccountResponseMessage, + quantity: int, + short_strike: float, ) -> Union[baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, None]: """Searches an option chain for the optimal strike.""" logger.debug("get_offsetting_strike") - # Set Variables - max_strike = list(strikes.keys())[0] - best_strike = list(strikes.values())[0] - # Iterate through strikes, select the largest strike with a minimum ask price over 0 - for strike, details in strikes.items(): - if 0 < details.ask < best_strike.ask or ( - details.ask == best_strike.ask and strike > max_strike - ): - max_strike = strike - best_strike = details + if self.buy_or_sell == "BUY": + logger.error("Cannot buy a max-width spread.") + return None - # Return the strike with the highest premium - return best_strike + # Get Buying Power + buying_power = self.calculate_strategy_buying_power( + account.currentbalances.liquidationvalue + ) + + # Determine max spread for the available buying power. + max_strike_width = buying_power / 100 / quantity + + # Initialize values + best_mid = float("inf") + best_strike = 0.0 + + for strike, detail in strikes.items(): + # Calc mid-price + mid = (detail.bid + detail.ask) / 2 + + # Determine if our strike fits the parameters + good_strike_width = max_strike_width <= abs(short_strike - best_strike) + good_strike_position = ( + (best_strike < strike) + if (self.put_or_call == "PUT") + else (best_strike > strike) + ) + good_strike = (0.00 < mid < best_mid) or ( + (mid == best_mid) and good_strike_position and good_strike_width + ) + + # If the mid-price is lower, use it + # If we're selling a PUT and the mid price is the same, but the strike is higher, use it. + # If we're selling a CALL and the mid price is the same, but the strike is lower, use it. + if good_strike: + logger.info( + f"Risk: {(abs(strike-best_strike)*detail.multiplier)}, Buying Power: {buying_power}" + ) + best_strike = strike + best_mid = mid + + # Return the strike + return strikes[best_strike] #################### ### Market Hours ### @@ -709,11 +1209,25 @@ def get_next_market_hours( self, date: dt.datetime = dt.datetime.now().astimezone(dt.timezone.utc), ) -> Union[baseRR.GetMarketHoursResponseMessage, None]: + """Returns the Market Hours for the next working session + + Args: + date (dt.datetime, optional): Date to start checking the market hours from. Defaults to dt.datetime.now().astimezone(dt.timezone.utc). + + Returns: + Union[baseRR.GetMarketHoursResponseMessage, None]: Market Hours of the next working session + """ + # Get the market hours hours = self.get_market_hours(date) - if hours is None or hours.end < dt.datetime.now().astimezone(dt.timezone.utc): + # Variable for current datetime + now = dt.datetime.now().astimezone(dt.timezone.utc) + + # If no hours were returned or the market is already closed for that day, search the next day. + if hours is None or hours.end < now: return self.get_next_market_hours(date + dt.timedelta(days=1)) + # Return the market hours return hours def sleep_until_market_open(self, datetime: dt.datetime): @@ -736,75 +1250,214 @@ def sleep_until_market_open(self, datetime: dt.datetime): ################### ### Calculators ### ################### - def calculate_actual_buying_power( - self, account: baseRR.GetAccountResponseMessage - ) -> float: - """Calculates the actual buying power based on the MaxLossCalcPercentage and current account balances. + def calculate_profit_target(self, strike_price: float) -> Union[None, float]: + """Calculates the profit target for the strategy based on the provided profit_target_percent in float or tuple Args: - account (baseRR.GetAccountResponseMessage): Account to calculate for + strike_price (float): Strike price to calculate the profit target against Returns: - float: Actual remaining buying power + Union[None, float]: Profit target formatted as a float """ - usedbp = 0.0 + # If it is a float, use the entered value + if isinstance(self.profit_target_percent, float): + if self.profit_target_percent == 1.0: + return None + return float(self.profit_target_percent) + + # If it is a tuple parse it as 1) Base PT, 2) %OTM Limit 3) Alternate PT + elif isinstance(self.profit_target_percent, tuple): + # Get current ticker price + get_quote_request = baseRR.GetQuoteRequestMessage( + self.strategy_id, [self.underlying] + ) + current_quote = self.mediator.get_quote(get_quote_request) - for position in account.positions: - if ( - position.underlyingsymbol == self.underlying - and position.putcall == self.put_or_call - ): - usedbp += self.calculate_position_buying_power(position) + if current_quote is None: + return None - return account.currentbalances.liquidationvalue - usedbp + current_price = current_quote.instruments[0].lastPrice - def calculate_position_buying_power( - self, position: baseRR.AccountPosition - ) -> float: - """Calculates the actual buying power for a given position + # Calculate opening position %OTM + percent_otm = abs((current_price - strike_price) / current_price) + + # Determine profit target % + if percent_otm < float(self.profit_target_percent[1]): + pt = float(self.profit_target_percent[0]) + else: + pt = float(self.profit_target_percent[2]) + + logger.info(f"OTM: {percent_otm*100}%, PT: {pt*100}%") + + return pt + + def calculate_strategy_buying_power(self, liquidation_value: float) -> float: + """Calculates the actual buying power based on the MaxLossCalcPercentage and current account balances. Args: - position (baseRR.AccountPosition): Account position to calculate + buying_power (float): Actual account buying power + liquidation_value (float): Actual account liquidation value Returns: - float: Required buying power + float: Maximum buying power for this strategy """ - return ( - position.strikeprice - * 100 - * position.shortquantity - * self.max_loss_calc_percent - ) + # Calculate how much this strategy could use, at most + return liquidation_value * self.portfolio_allocation_percent + + # Return the smaller value of our actual buying power and calculated maximum buying power + # return min(allocation_bp, buying_power) + + def calculate_quantity( + self, liquidation_value, days_to_expiration, strike, offset_strike + ) -> int: + # Calc quantity using the spread width + if self.max_loss_calc_method == "SPREAD" and offset_strike is not None: + return self.calculate_quantity_spread( + strike, offset_strike, liquidation_value, days_to_expiration + ) + # Calculate the quantity using the short-strike + else: + return self.calculate_quantity_single_strike( + strike, liquidation_value, days_to_expiration + ) - def calculate_order_quantity( - self, strike: float, buyingpower: float, liquidationvalue: float + def calculate_quantity_single_strike( + self, strike: float, liquidation_value: float, dte: int = 2 ) -> int: """Calculates the number of positions to open for a given account and strike.""" logger.debug("calculate_order_quantity") - # Calculate max loss per contract - max_loss = strike * 100 * float(self.max_loss_calc_percent) + max_loss_percent = self.get_max_loss_percentage(dte) + + if max_loss_percent is None: + return 0 + + max_loss = strike * 100 * max_loss_percent # Calculate max buying power to use - balance_to_risk = liquidationvalue * float(self.portfolio_allocation_percent) - - remainingbalance = buyingpower - (liquidationvalue - balance_to_risk) - - # Calculate trade size - trade_size = remainingbalance // max_loss - - # Log Values - # logger.info( - # "Strike: {} BuyingPower: {} LiquidationValue: {} MaxLoss: {} BalanceToRisk: {} RemainingBalance: {} TradeSize: {} ".format( - # strike, - # buyingpower, - # liquidationvalue, - # max_loss, - # balance_to_risk, - # remainingbalance, - # trade_size, - # ) - # ) + balance_to_risk = self.calculate_strategy_buying_power(liquidation_value) + + # Return quantity + return int(balance_to_risk // max_loss) + + def calculate_quantity_spread( + self, + strike: float, + offset_strike: Union[None, float], + liquidation_value: float, + dte: int = 2, + ) -> int: + """Calculates the number of positions to open for a given account and strike.""" + logger.debug("calculate_order_quantity") + + if offset_strike is not None: + max_loss = abs(strike - offset_strike) * 100 + else: + max_loss_percent = self.get_max_loss_percentage(dte) + + if max_loss_percent is None: + return 0 + + max_loss = strike * 100 * max_loss_percent + + # Calculate max buying power to use + balance_to_risk = self.calculate_strategy_buying_power(liquidation_value) # Return quantity - return int(trade_size) + return int(balance_to_risk // max_loss) + + def calculate_offset_leg_quantity( + self, target_qty: int, expiration_date: dt.date + ) -> int: + """Calculates the quantity for an offset leg based on what has already been purchased for the given expiration. + + Args: + target_qty (int): How many positions we need to offset + expiration_date (dt.date): The date we need to offset on + + Returns: + int: Quantity of offset legs needed. + """ + # Get all offsetting legs on the date provided + long_offsets = self.get_current_offsets(expiration_date) + + # If we don't have any, return the full amount + if long_offsets is None: + return target_qty + + # If we do have offsets, sum up the quantity + open_offset_qty = sum(leg.quantity for leg in long_offsets) + + # Get working, closing orders to determine how many positions are accounted for + req = baseRR.ReadOpenDatabaseOrdersRequest(strategy_id=self.strategy_id) + open_orders = self.mediator.read_active_orders(req) + + # If we don't have any open orders, return either the difference between our target qty and open offset qty, or 0, whichever is larger + if open_orders is None: + return max(target_qty - open_offset_qty, 0) + + for order in open_orders.orders: + for leg in order.legs: + if ( + leg.position_effect == "CLOSING" + and leg.put_call == self.put_or_call + ): + open_offset_qty -= leg.quantity + + # Return either the difference between our target qty and actual qty, or 0, whichever is larger + return max(target_qty - open_offset_qty, 0) + + def calculate_total_premium( + self, + primary_strike: baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, + offset_strike: Union[ + None, baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike + ], + qty: int, + ) -> float: + """Calculates the total premium for a given set of strikes and quantity. + + Args: + primary_strike (baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike): The main strike for the strategy + offset_strike (Union[None,baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike]): The optional offsetting strike + qty (int): Quantity for the order + + Returns: + float: Total Premium for the legs + """ + # Add up primary strike premium + bid_ask_total = 0.0 + bid_ask_total += primary_strike.bid + bid_ask_total += primary_strike.ask + + # If no offset strike, return the average premium + if offset_strike is None: + return qty * bid_ask_total / 2 + + # If there is an offset strike, subtract the premium + bid_ask_total -= offset_strike.bid + bid_ask_total -= offset_strike.ask + + # Return the average + return qty * bid_ask_total / 2 + + def calculate_delta( + self, + underlying_last_price: float, + strike: float, + risk_free_rate: float, + days_to_expiration: int, + detail, + ) -> float: + if self.use_vollib_for_greeks: + return helpers.calculate_delta( + underlying_last_price, + strike, + risk_free_rate, + days_to_expiration, + self.put_or_call, + None, + (detail.bid + detail.ask) / 2, + ) + + return detail.delta diff --git a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py index a706cb3..6af73e3 100644 --- a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py +++ b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py @@ -47,6 +47,9 @@ class SpreadsByDeltaStrategy(Strategy, Component): default=dt.datetime.now().astimezone(dt.timezone.utc), validator=attr.validators.instance_of(dt.datetime), ) + minutes_before_close: int = attr.ib( + default=5, validator=attr.validators.instance_of(int) + ) # Core Strategy Process def process_strategy(self): @@ -58,7 +61,7 @@ def process_strategy(self): # Check if should be sleeping if now < self.sleepuntil: - logger.debug("Markets Closed. Sleeping until {}".format(self.sleepuntil)) + logger.debug(f"Markets Closed. Sleeping until {self.sleepuntil}") return # Check market hours @@ -72,18 +75,21 @@ def process_strategy(self): if hours.start.day != now.day: self.sleepuntil = hours.end - dt.timedelta(minutes=10) logger.info( - "Markets are closed until {}. Sleeping until {}".format( - hours.start, self.sleepuntil - ) + f"Markets are closed until {hours.start}. Sleeping until {self.sleepuntil}" ) + return # If Pre-Market - if now < (hours.end - dt.timedelta(minutes=10)): + if now < (hours.end - dt.timedelta(minutes=self.minutes_before_close)): self.process_pre_market() # If In-Market - elif (hours.end - dt.timedelta(minutes=10)) < now < hours.end: + elif ( + (hours.end - dt.timedelta(minutes=self.minutes_before_close)) + < now + < hours.end + ): self.process_open_market() def process_pre_market(self): @@ -95,14 +101,13 @@ def process_pre_market(self): # Set sleepuntil self.sleepuntil = ( - nextmarketsession.end - dt.timedelta(minutes=10) - dt.timedelta(minutes=5) + nextmarketsession.end + - dt.timedelta(minutes=self.minutes_before_close) + - dt.timedelta(minutes=5) ) logger.info( - "Markets are closed until {}. Sleeping until {}".format( - nextmarketsession.start, - self.sleepuntil, - ) + f"Markets are closed until {nextmarketsession.start}. Sleeping until {self.sleepuntil}" ) def process_open_market(self): @@ -122,11 +127,7 @@ def place_new_orders_loop(self) -> None: if neworder is None: return - # Place the order and check the result - result = self.place_order(neworder) - - # If successful, return - if result: + if self.place_order(neworder): return # Otherwise, try again @@ -270,17 +271,11 @@ def build_leg_instruction(self, short_or_long: str) -> str: def build_new_order_precheck( self, account: baseRR.GetAccountResponseMessage ) -> bool: - # Check if we have positions on already that expire today. - nonexpiring = any( + if any( position.underlyingsymbol == self.underlying and position.expirationdate.date() != dt.date.today() for position in account.positions - ) - - # If nothing is expiring and no tradable balance, exit. - # If we are expiring, continue trying to place a trade - # If we have a tradable balance, continue trying to place a trade - if nonexpiring: + ): return False # Check if we have positions on already that expire today. @@ -391,7 +386,7 @@ def get_next_expiration( """Checks an option chain response for the next expiration date.""" logger.debug("get_next_expiration") - if expirations is None or expirations == []: + if expirations is None or not expirations: logger.error("No expirations provided.") return None @@ -486,16 +481,7 @@ def calculate_order_quantity( # Log Values logger.info( - "Short Strike: {} Long Strike: {} BuyingPower: {} LiquidationValue: {} MaxLoss: {} BalanceToRisk: {} RemainingBalance: {} TradeSize: {} ".format( - shortstrike, - longstrike, - account_balance.buyingpower, - account_balance.liquidationvalue, - max_loss, - balance_to_risk, - remainingbalance, - trade_size, - ) + f"Short Strike: {shortstrike} Long Strike: {longstrike} BuyingPower: {account_balance.buyingpower} LiquidationValue: {account_balance.liquidationvalue} MaxLoss: {max_loss} BalanceToRisk: {balance_to_risk} RemainingBalance: {remainingbalance} TradeSize: {trade_size} " ) # Return quantity diff --git a/test/broker/test_tdaBroker.py b/test/broker/test_tdaBroker.py index 8f71ae3..1cc57fa 100644 --- a/test/broker/test_tdaBroker.py +++ b/test/broker/test_tdaBroker.py @@ -1,14 +1,11 @@ -# import datetime as dt - -# import basetypes.Mediator.reqRespTypes as baseRR # from basetypes.Broker.tdaBroker import TdaBroker - +# import basetypes.Mediator.reqRespTypes as baseRR +# import datetime as dt # def test_get_account(): # broker = TdaBroker(id="individual") -# broker.maxretries = 3 -# request = baseRR.GetAccountRequestMessage("", True, True) +# request = baseRR.GetAccountRequestMessage(2, True, True) # response = broker.get_account(request) # assert response is not None @@ -20,50 +17,43 @@ # def test_get_quote(): # broker = TdaBroker(id="individual") -# broker.maxretries = 3 -# request = baseRR.GetQuoteRequestMessage('', ['VGSH']) +# request = baseRR.GetQuoteRequestMessage(2, ['VGSH']) # response = broker.get_quote(request) # assert response is not None +# assert response.instruments is not None +# assert response.instruments[0].symbol == "VGSH" +# assert response.instruments[0].askPrice > 0 # def test_get_order(): -# broker = TdaBroker() -# broker.maxretries = 3 +# broker = TdaBroker(id="individual") # requestorderid = 4240878201 -# request = baseRR.GetOrderRequestMessage(requestorderid) +# request = baseRR.GetOrderRequestMessage(2,requestorderid) # response = broker.get_order(request) # assert response is not None -# assert response.orderid == requestorderid +# assert response.order is not None +# assert response.order.order_id == requestorderid # def test_cancel_order(): -# broker = TdaBroker() -# broker.maxretries = 3 +# broker = TdaBroker(id="individual") # requestorderid = 4240878201 -# request = baseRR.CancelOrderRequestMessage(requestorderid) +# request = baseRR.CancelOrderRequestMessage(2,requestorderid) # response = broker.cancel_order(request) # assert response is not None # assert response.responsecode == 200 -# RUN AT YOUR OWN RISK, THIS COULD OPEN NEW POSITIONS ON YOUR ACCOUNT. YOU MAY NEED TO REVISE THE SYMBOL -# def test_place_order(self): -# request = baseRR.PlaceOrderRequestMessage(price=.01, quantity=1, symbol='AAPL_040521P60') -# response = self.func.place_order(request) - -# self.assertIsNotNone(response) - - # def test_get_option_chain(): -# broker = TdaBroker() -# broker.maxretries = 3 +# broker = TdaBroker(id="individual") # request = baseRR.GetOptionChainRequestMessage( +# strategy_id=2, # symbol="$SPX.X", # contracttype="PUT", # includequotes=True, @@ -91,10 +81,9 @@ # def test_get_market_hours(): -# broker = TdaBroker() -# broker.maxretries = 3 +# broker = TdaBroker(id="individual") -# request = baseRR.GetMarketHoursRequestMessage( +# request = baseRR.GetMarketHoursRequestMessage(strategy_id=2, # datetime=dt.datetime.now(), market="OPTION", product="IND" # ) # response = broker.get_market_hours(request)