From 4380f666e3c239a2a32964ebdcad7df805d2fad2 Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 7 Feb 2022 00:16:12 +0000 Subject: [PATCH 01/34] Initial Commit --- .../Strategy/spreadsbydeltastrategy.py | 65 ++++++++++++------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py index 611983e..d764f6f 100644 --- a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py +++ b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py @@ -36,7 +36,9 @@ class SpreadsByDeltaStrategy(Strategy, Component): targetdelta: float = attr.ib( default=-0.10, validator=attr.validators.instance_of(float) ) - width: float = attr.ib(default=70.0, validator=attr.validators.instance_of(float)) + width: float = attr.ib( + default=float("inf"), validator=attr.validators.instance_of(float) + ) minimumdte: int = attr.ib(default=1, validator=attr.validators.instance_of(int)) maximumdte: int = attr.ib(default=4, validator=attr.validators.instance_of(int)) openingorderloopseconds: int = attr.ib( @@ -123,10 +125,7 @@ def place_new_orders_loop(self) -> 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 @@ -188,7 +187,7 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: if short_strike is None: return None - long_strike = self.get_long_strike(expiration.strikes, short_strike.strike) + long_strike = self.get_long_strike(expiration.strikes, short_strike) # If no valid long strike, exit. if long_strike is None: @@ -271,16 +270,14 @@ def build_new_order_precheck( self, account: baseRR.GetAccountResponseMessage ) -> bool: # Check if we have positions on already that expire today. - nonexpiring = 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: + if any( + position.underlyingsymbol == self.underlying + and position.expirationdate.date() != dt.date.today() + for position in account.positions + ): return False # Check if we have positions on already that expire today. @@ -391,7 +388,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 @@ -442,21 +439,43 @@ def get_long_strike( strikes: dict[ float, baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike ], - short_strike: float, + first_strike: baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, ) -> Union[baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, None]: """Searches an option chain for the optimal strike.""" logger.debug("get_best_strike") - new_strike = short_strike - self.width - best_strike = 0.0 - best_delta = 1000000.0 - for strike in strikes: - delta = strike - new_strike - if abs(delta) < best_delta: - best_strike = strike - best_delta = abs(delta) + # If Max Width, find cheapest long + if self.width == float("inf"): + best_bid = float("inf") + + for strike, detail in strikes.items(): + # Calculate distance between strikes + if self.buy_or_sell == "SELL" and 0 < detail.bid <= best_bid: + if (self.put_or_call == "PUT" and strike < first_strike.strike) or ( + self.put_or_call == "CALL" and strike > first_strike.strike + ): + best_strike = strike + best_bid = detail.bid + elif self.buy_or_sell == "BUY" and detail.bid >= best_bid: + if (self.put_or_call == "PUT" and strike > first_strike.strike) or ( + self.put_or_call == "CALL" and strike < first_strike.strike + ): + best_strike = strike + best_bid = detail.bid + + # Else, find the matching spread + else: + best_strike = 0.0 + best_delta = 1000000.0 + new_strike = first_strike.strike - self.width + + for strike in strikes: + delta = strike - new_strike + if abs(delta) < best_delta: + best_strike = strike + best_delta = abs(delta) # Return the strike return strikes[best_strike] From 187e42b2410c4b45d405e0225c1a9d259c100e8f Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 7 Feb 2022 12:30:22 +0000 Subject: [PATCH 02/34] code cleanup --- .../Strategy/spreadsbydeltastrategy.py | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py index d764f6f..83e1d8a 100644 --- a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py +++ b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py @@ -448,24 +448,22 @@ def get_long_strike( # If Max Width, find cheapest long if self.width == float("inf"): + + if self.buy_or_sell == "BUY": + logger.error("Cannot buy a max-width spread.") + return None + best_bid = float("inf") for strike, detail in strikes.items(): # Calculate distance between strikes - if self.buy_or_sell == "SELL" and 0 < detail.bid <= best_bid: - if (self.put_or_call == "PUT" and strike < first_strike.strike) or ( - self.put_or_call == "CALL" and strike > first_strike.strike - ): - best_strike = strike - best_bid = detail.bid - elif self.buy_or_sell == "BUY" and detail.bid >= best_bid: - if (self.put_or_call == "PUT" and strike > first_strike.strike) or ( - self.put_or_call == "CALL" and strike < first_strike.strike - ): - best_strike = strike - best_bid = detail.bid - - # Else, find the matching spread + if 0 < detail.bid <= best_bid and ( + (self.put_or_call == "PUT" and strike < first_strike.strike) + or (self.put_or_call == "CALL" and strike > first_strike.strike) + ): + best_strike = strike + best_bid = detail.bid + else: best_strike = 0.0 best_delta = 1000000.0 From 9461da643cb5833308c33582c8a592525070820d Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 7 Feb 2022 13:09:20 +0000 Subject: [PATCH 03/34] more cleanup --- .../basetypes/Strategy/spreadsbydeltastrategy.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py index 83e1d8a..6ebee8b 100644 --- a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py +++ b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py @@ -444,7 +444,7 @@ def get_long_strike( """Searches an option chain for the optimal strike.""" logger.debug("get_best_strike") - best_strike = 0.0 + best_strike = 0.0 if self.put_or_call == "PUT" else float("inf") # If Max Width, find cheapest long if self.width == float("inf"): @@ -456,16 +456,22 @@ def get_long_strike( best_bid = float("inf") for strike, detail in strikes.items(): - # Calculate distance between strikes + # If the bid is lower or the same and the strike is closer than our best_strike to our first strike, use it. if 0 < detail.bid <= best_bid and ( - (self.put_or_call == "PUT" and strike < first_strike.strike) - or (self.put_or_call == "CALL" and strike > first_strike.strike) + ( + self.put_or_call == "PUT" + and best_strike < strike < first_strike.strike + ) + or ( + self.put_or_call == "CALL" + and best_strike > strike > first_strike.strike + ) ): best_strike = strike best_bid = detail.bid + # Otherwise get closest strike to the set width else: - best_strike = 0.0 best_delta = 1000000.0 new_strike = first_strike.strike - self.width From 48b3c3f4c9138cf865f2b40a1b446fbba88528a4 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Mon, 7 Feb 2022 23:31:33 +0000 Subject: [PATCH 04/34] refinements --- looptrader/__main__.py | 3 ++ .../Strategy/spreadsbydeltastrategy.py | 41 +++++++++++-------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/looptrader/__main__.py b/looptrader/__main__.py index 55a4ace..58ea74e 100644 --- a/looptrader/__main__.py +++ b/looptrader/__main__.py @@ -37,6 +37,8 @@ ) spreadstrat = SpreadsByDeltaStrategy(strategy_name="spreads") + # testspreads = SpreadsByDeltaStrategy(strategy_name="test spreads", width=float("inf"), put_or_call="CALL", targetdelta=.07, underlying="$XSP.X", portfolioallocationpercent=.002, minutes_before_close=390) + # Create our brokers individualbroker = TdaBroker(id="individual") irabroker = TdaBroker(id="ira") @@ -54,6 +56,7 @@ cspstrat: individualbroker, nakedcalls: individualbroker, vgshstrat: individualbroker, + # testspreads: individualbroker, }, database=sqlitedb, notifier=telegram_bot, diff --git a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py index 6ebee8b..24536e8 100644 --- a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py +++ b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py @@ -49,6 +49,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=10, validator=attr.validators.instance_of(int) + ) # Core Strategy Process def process_strategy(self): @@ -72,7 +75,9 @@ def process_strategy(self): # If the next market session is not today, wait until 10 minutes before close if hours.start.day != now.day: - self.sleepuntil = hours.end - dt.timedelta(minutes=10) + self.sleepuntil = hours.end - dt.timedelta( + minutes=self.minutes_before_close + ) logger.info( "Markets are closed until {}. Sleeping until {}".format( hours.start, self.sleepuntil @@ -81,11 +86,15 @@ def process_strategy(self): 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): @@ -96,8 +105,8 @@ def process_pre_market(self): nextmarketsession = self.get_market_session_loop(dt.datetime.now()) # Set sleepuntil - self.sleepuntil = ( - nextmarketsession.end - dt.timedelta(minutes=10) - dt.timedelta(minutes=5) + self.sleepuntil = nextmarketsession.end - dt.timedelta( + minutes=self.minutes_before_close ) logger.info( @@ -161,7 +170,7 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: fromdate=startdate, todate=enddate, symbol=self.underlying, - includequotes=False, + includequotes=True, optionrange="OTM", ) @@ -177,7 +186,7 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: expiration = self.get_next_expiration(chain.callexpdatemap) # If no valid expirations, exit. - if expiration is None: + if expiration is None or expiration.strikes is None: return None # Get the short strike @@ -287,6 +296,9 @@ def build_new_order_precheck( for position in account.positions ) + if self.width == float("inf"): + return True + # Check Available Balance tradable_today = ( account.currentbalances.liquidationvalue * self.portfolioallocationpercent @@ -444,7 +456,7 @@ def get_long_strike( """Searches an option chain for the optimal strike.""" logger.debug("get_best_strike") - best_strike = 0.0 if self.put_or_call == "PUT" else float("inf") + best_strike = 0.0 # If Max Width, find cheapest long if self.width == float("inf"): @@ -453,22 +465,19 @@ def get_long_strike( logger.error("Cannot buy a max-width spread.") return None - best_bid = float("inf") + best_ask = float("inf") for strike, detail in strikes.items(): - # If the bid is lower or the same and the strike is closer than our best_strike to our first strike, use it. - if 0 < detail.bid <= best_bid and ( + # If the ask is lower or the same and the strike is closer than our best_strike to our first strike, use it. + if 0.00 < detail.ask <= best_ask and ( ( self.put_or_call == "PUT" and best_strike < strike < first_strike.strike ) - or ( - self.put_or_call == "CALL" - and best_strike > strike > first_strike.strike - ) + or (self.put_or_call == "CALL" and strike > best_strike) ): best_strike = strike - best_bid = detail.bid + best_ask = detail.ask # Otherwise get closest strike to the set width else: From 519efc947b3c91c809d8ce7c7e2d1f42f36dc6ba Mon Sep 17 00:00:00 2001 From: Tyler Date: Tue, 8 Feb 2022 08:25:29 +0000 Subject: [PATCH 05/34] fixing calculation --- .../basetypes/Strategy/spreadsbydeltastrategy.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py index 24536e8..593e67c 100644 --- a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py +++ b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py @@ -222,7 +222,7 @@ def build_order_request( # Calculate price price = ( - short_strike.bid + short_strike.ask - (long_strike.bid + long_strike.ask) + short_strike.bid + short_strike.ask - long_strike.bid + long_strike.ask ) / 2 formattedprice = self.format_order_price(price) @@ -465,11 +465,12 @@ def get_long_strike( logger.error("Cannot buy a max-width spread.") return None - best_ask = float("inf") + best_mid = float("inf") for strike, detail in strikes.items(): - # If the ask is lower or the same and the strike is closer than our best_strike to our first strike, use it. - if 0.00 < detail.ask <= best_ask and ( + mid = (detail.bid + detail.ask) / 2 + # If the mid-price is lower or the same and the strike is closer than our best_strike to our first strike, use it. + if 0.00 < mid <= best_mid and ( ( self.put_or_call == "PUT" and best_strike < strike < first_strike.strike @@ -477,7 +478,7 @@ def get_long_strike( or (self.put_or_call == "CALL" and strike > best_strike) ): best_strike = strike - best_ask = detail.ask + best_mid = mid # Otherwise get closest strike to the set width else: From dc17ef3e8fa973606c1486eb5b89ebef20bed5e7 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Tue, 8 Feb 2022 16:31:41 +0000 Subject: [PATCH 06/34] date bug fix --- looptrader/basetypes/Broker/tdaBroker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/looptrader/basetypes/Broker/tdaBroker.py b/looptrader/basetypes/Broker/tdaBroker.py index b5e57da..6235c6d 100644 --- a/looptrader/basetypes/Broker/tdaBroker.py +++ b/looptrader/basetypes/Broker/tdaBroker.py @@ -666,7 +666,7 @@ def translate_account_position(position: dict): if desc is not None: match = re.search( - r"([A-Z]{1}[a-z]{2} \d{2} \d{4})", instrument.get("description") + r"([A-Z]{1}[a-z]{2} \d{1,2} \d{4})", instrument.get("description") ) if match is not None: accountposition.expirationdate = dtime.datetime.strptime( From 4670bd2f1cf21bd4fc785dc7894542d5a4bd8862 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Tue, 8 Feb 2022 17:27:06 +0000 Subject: [PATCH 07/34] revised logic. May move into singlebydelta strat --- looptrader/__main__.py | 2 +- .../Strategy/singlebydeltastrategy.py | 36 +++++++---- .../Strategy/spreadsbydeltastrategy.py | 61 +++++++++++++------ 3 files changed, 69 insertions(+), 30 deletions(-) diff --git a/looptrader/__main__.py b/looptrader/__main__.py index 58ea74e..648e1fe 100644 --- a/looptrader/__main__.py +++ b/looptrader/__main__.py @@ -37,7 +37,7 @@ ) spreadstrat = SpreadsByDeltaStrategy(strategy_name="spreads") - # testspreads = SpreadsByDeltaStrategy(strategy_name="test spreads", width=float("inf"), put_or_call="CALL", targetdelta=.07, underlying="$XSP.X", portfolioallocationpercent=.002, minutes_before_close=390) + # testspreads = SpreadsByDeltaStrategy(strategy_name="test spreads", width=float("inf"), put_or_call="CALL", targetdelta=.03, portfolioallocationpercent=2.0, minutes_before_close=390) # Create our brokers individualbroker = TdaBroker(id="individual") diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index f1d7ac6..5802543 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -676,20 +676,34 @@ def get_offsetting_strike( ) -> 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 + best_strike = 0.0 + + if self.buy_or_sell == "BUY": + logger.error("Cannot buy a max-width spread.") + return None + + best_mid = float("inf") + + for strike, detail in strikes.items(): + mid = (detail.bid + detail.ask) / 2 + + # If the mid-price is lower or the same, and the strike is closer than our best_strike to our first strike, use it. + if 0.00 < mid < best_mid: + best_strike = strike + best_mid = mid + elif self.put_or_call == "PUT" and mid == best_mid and best_strike < strike: + best_strike = strike + best_mid = mid + # If the mid-price is lower, or the same and the strike is closer than our best_strike to our first strike, use it. + elif ( + self.put_or_call == "CALL" and mid == best_mid and best_strike > strike ): - max_strike = strike - best_strike = details + best_strike = strike + best_mid = mid - # Return the strike with the highest premium - return best_strike + # Return the strike + return strikes[best_strike] #################### ### Market Hours ### diff --git a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py index 593e67c..913619b 100644 --- a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py +++ b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py @@ -222,7 +222,7 @@ def build_order_request( # Calculate price price = ( - short_strike.bid + short_strike.ask - long_strike.bid + long_strike.ask + short_strike.bid + short_strike.ask - long_strike.bid - long_strike.ask ) / 2 formattedprice = self.format_order_price(price) @@ -278,6 +278,10 @@ def build_leg_instruction(self, short_or_long: str) -> str: def build_new_order_precheck( self, account: baseRR.GetAccountResponseMessage ) -> bool: + + if self.width == float("inf"): + return True + # Check if we have positions on already that expire today. # If nothing is expiring and no tradable balance, exit. # If we are expiring, continue trying to place a trade @@ -296,9 +300,6 @@ def build_new_order_precheck( for position in account.positions ) - if self.width == float("inf"): - return True - # Check Available Balance tradable_today = ( account.currentbalances.liquidationvalue * self.portfolioallocationpercent @@ -469,16 +470,32 @@ def get_long_strike( for strike, detail in strikes.items(): mid = (detail.bid + detail.ask) / 2 - # If the mid-price is lower or the same and the strike is closer than our best_strike to our first strike, use it. - if 0.00 < mid <= best_mid and ( - ( - self.put_or_call == "PUT" - and best_strike < strike < first_strike.strike - ) - or (self.put_or_call == "CALL" and strike > best_strike) + + # If the mid-price is lower or the same, and the strike is closer than our best_strike to our first strike, use it. + if 0.00 < mid < best_mid: + best_strike = strike + best_mid = mid + elif (self.put_or_call == "PUT") and ( + (mid == best_mid) and (best_strike < strike < first_strike.strike) ): best_strike = strike best_mid = mid + # If the mid-price is lower, or the same and the strike is closer than our best_strike to our first strike, use it. + elif self.put_or_call == "CALL" and ( + (mid == best_mid) and (best_strike > strike > first_strike.strike) + ): + best_strike = strike + best_mid = mid + + # if 0.00 < mid <= best_mid and ( + # ( + # self.put_or_call == "PUT" + # and best_strike < strike < first_strike.strike + # ) + # or (self.put_or_call == "CALL" and strike > best_strike) + # ): + # best_strike = strike + # best_mid = mid # Otherwise get closest strike to the set width else: @@ -503,30 +520,38 @@ def calculate_order_quantity( """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 = abs(shortstrike - longstrike) * 100 - # Calculate max buying power to use balance_to_risk = account_balance.liquidationvalue * float( self.portfolioallocationpercent ) - remainingbalance = account_balance.buyingpower + # Calculate max loss per contract + if self.width == float("inf"): + # Calculate max loss per contract + max_loss = shortstrike * 100 * 0.2 + + trading_power = account_balance.buyingpower - ( + account_balance.liquidationvalue - balance_to_risk + ) + + else: + max_loss = abs(shortstrike - longstrike) * 100 + + trading_power = min(balance_to_risk, account_balance.buyingpower) - trading_power = min(balance_to_risk, remainingbalance) # Calculate trade size trade_size = trading_power // max_loss # Log Values logger.info( - "Short Strike: {} Long Strike: {} BuyingPower: {} LiquidationValue: {} MaxLoss: {} BalanceToRisk: {} RemainingBalance: {} TradeSize: {} ".format( + "Short Strike: {} Long Strike: {} BuyingPower: {} LiquidationValue: {} MaxLoss: {} BalanceToRisk: {} TradingPower: {} TradeSize: {} ".format( shortstrike, longstrike, account_balance.buyingpower, account_balance.liquidationvalue, max_loss, balance_to_risk, - remainingbalance, + trading_power, trade_size, ) ) From 35a5e4e3d841450a01333f70d0cf52f889fbb813 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 9 Feb 2022 00:31:37 +0000 Subject: [PATCH 08/34] moving max_spread logic to single strat as offset --- .../Strategy/singlebydeltastrategy.py | 211 ++++++++++-------- .../Strategy/spreadsbydeltastrategy.py | 15 +- 2 files changed, 117 insertions(+), 109 deletions(-) diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index 5802543..38544cb 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -74,6 +74,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=True, validator=attr.validators.instance_of(bool) + ) # Core Strategy Process def process_strategy(self): @@ -257,10 +260,7 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: 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: @@ -280,16 +280,61 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: if strike is None: return None + # If we should immediate offset positions, get the second leg. + if self.offset_sold_positions: + offset_strike = self.get_offsetting_strike(expiration.strikes) + + # If no valid strikes, exit. + if offset_strike is None: + return None + # Calculate Quantity qty = self.calculate_order_quantity( strike.strike, availbp, account.currentbalances.liquidationvalue ) + # Return Order + return self.build_opening_order_request(strike, offset_strike, qty=qty) + + def build_opening_order_request( + self, + strike: baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, + offset_strike: Union[ + baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, None + ], + qty: int, + offsetting: bool = False, + ) -> Union[baseRR.PlaceOrderRequestMessage, None]: + + # If no valid qty, exit. + if qty is None or qty <= 0: + return None + + # Build Base Order + order_request = self.build_base_order_request_message() + + # Build the first leg and append + first_leg = self.build_leg( + strike.symbol, qty, "BUY" if offsetting else self.buy_or_sell, True + ) + order_request.order.legs.append(first_leg) + # Calculate price - formattedprice = helpers.format_order_price((strike.bid + strike.ask) / 2) + price = (strike.bid + strike.ask) / 2 - # Return Order - return self.build_opening_order_request(strike, qty, formattedprice) + # If we have an offset_strike... + if offset_strike is not None: + # Build Long Leg and append + long_leg = self.build_leg(offset_strike.symbol, qty, "BUY", True) + order_request.order.legs.append(long_leg) + + # Subtract the offset, if it exists + price = price - (offset_strike.bid + offset_strike.ask) / 2 + + # Set the price + order_request.order.price = helpers.format_order_price(price) + + return order_request def build_offsetting_order( self, qty: int @@ -308,7 +353,7 @@ 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] # Find best strike to trade @@ -319,45 +364,33 @@ def build_offsetting_order( return None # Return Order - return self.build_opening_order_request( - strike, qty, strike.ask, offsetting=True - ) + return self.build_opening_order_request(strike, None, qty, offsetting=True) - def build_opening_order_request( - self, - strike: baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, - qty: int, - price: float, - offsetting: bool = False, - ) -> baseRR.PlaceOrderRequestMessage: # sourcery skip: class-extract-method - """Builds an order request to open a new postion + def new_build_closing_order( + self, original_order: baseModels.Order + ) -> baseRR.PlaceOrderRequestMessage: + """Builds a closing order request message for a given position.""" - Args: - strike (baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike): The strike to trade - qty (int): The number of contracts - price (float): Contract Price + # Build base order + order_request = self.build_base_order_request_message() - 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" + # Build and append new legs + for leg in original_order.legs: + instruction = "BUY" if leg.instruction == "SELL_TO_OPEN" else "SELL" + new_leg = self.build_leg( + leg.symbol, leg.quantity, instruction, opening=False + ) + order_request.order.legs.append(new_leg) - if ( - offsetting - and self.buy_or_sell == "SELL" - or not offsetting - and self.buy_or_sell != "SELL" - ): - leg.instruction = "BUY_TO_OPEN" - else: - leg.instruction = "SELL_TO_OPEN" + # Calculate and enter price + order_request.order.price = helpers.format_order_price( + original_order.price * (1 - float(self.profit_target_percent)) + ) - # Build Order + # Return request + return order_request + + def build_base_order_request_message(self): orderrequest = baseRR.PlaceOrderRequestMessage() orderrequest.order = baseModels.Order() orderrequest.order.strategy_id = self.strategy_id @@ -365,44 +398,32 @@ def build_opening_order_request( orderrequest.order.duration = "GOOD_TILL_CANCEL" orderrequest.order.order_type = "LIMIT" 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, quantity: int, buy_or_sell: str, opening: bool + ) -> baseModels.OrderLeg: leg = baseModels.OrderLeg() - leg.symbol = original_order.legs[0].symbol + leg.symbol = symbol 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 + return leg ##################### ### Order Placers ### @@ -585,21 +606,25 @@ 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.") - return None + # Determine which expiration map to use + if self.put_or_call == "CALL": + expirations = chain.callexpdatemap + + elif self.put_or_call == "PUT": + expirations = chain.putexpdatemap # 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: @@ -677,27 +702,32 @@ def get_offsetting_strike( """Searches an option chain for the optimal strike.""" logger.debug("get_offsetting_strike") - best_strike = 0.0 - if self.buy_or_sell == "BUY": logger.error("Cannot buy a max-width spread.") return None best_mid = float("inf") + best_strike = 0.0 for strike, detail in strikes.items(): + # Calc mid-price mid = (detail.bid + detail.ask) / 2 - # If the mid-price is lower or the same, and the strike is closer than our best_strike to our first strike, use it. + # If the mid-price is lower, use it if 0.00 < mid < best_mid: best_strike = strike best_mid = mid - elif self.put_or_call == "PUT" and mid == best_mid and best_strike < strike: + + # If we're selling a PUT and the mid price is the same, but the strike is higher, use it. + elif (self.put_or_call == "PUT") and ( + (mid == best_mid) and (best_strike < strike) + ): best_strike = strike best_mid = mid - # If the mid-price is lower, or the same and the strike is closer than our best_strike to our first strike, use it. - elif ( - self.put_or_call == "CALL" and mid == best_mid and best_strike > strike + + # If we're selling a CALL and the mid price is the same, but the strike is lower, use it. + elif self.put_or_call == "CALL" and ( + (mid == best_mid) and (best_strike > strike) ): best_strike = strike best_mid = mid @@ -807,18 +837,5 @@ def calculate_order_quantity( # 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, - # ) - # ) - # Return quantity return int(trade_size) diff --git a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py index 913619b..7c162ff 100644 --- a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py +++ b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py @@ -471,32 +471,23 @@ def get_long_strike( for strike, detail in strikes.items(): mid = (detail.bid + detail.ask) / 2 - # If the mid-price is lower or the same, and the strike is closer than our best_strike to our first strike, use it. + # If the mid-price is lower, use it if 0.00 < mid < best_mid: best_strike = strike best_mid = mid + # If we're selling a PUT and the mid price is the same, but the strike is higher, use it. elif (self.put_or_call == "PUT") and ( (mid == best_mid) and (best_strike < strike < first_strike.strike) ): best_strike = strike best_mid = mid - # If the mid-price is lower, or the same and the strike is closer than our best_strike to our first strike, use it. + # If we're selling a CALL and the mid price is the same, but the strike is lower, use it. elif self.put_or_call == "CALL" and ( (mid == best_mid) and (best_strike > strike > first_strike.strike) ): best_strike = strike best_mid = mid - # if 0.00 < mid <= best_mid and ( - # ( - # self.put_or_call == "PUT" - # and best_strike < strike < first_strike.strike - # ) - # or (self.put_or_call == "CALL" and strike > best_strike) - # ): - # best_strike = strike - # best_mid = mid - # Otherwise get closest strike to the set width else: best_delta = 1000000.0 From e9b9d16181c07665b141cc4a38ad02b304d01c05 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 9 Feb 2022 00:40:15 +0000 Subject: [PATCH 09/34] reverting spreads logic. --- .../Strategy/spreadsbydeltastrategy.py | 106 +++++------------- 1 file changed, 28 insertions(+), 78 deletions(-) diff --git a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py index 7c162ff..65c555f 100644 --- a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py +++ b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py @@ -36,9 +36,7 @@ class SpreadsByDeltaStrategy(Strategy, Component): targetdelta: float = attr.ib( default=-0.10, validator=attr.validators.instance_of(float) ) - width: float = attr.ib( - default=float("inf"), validator=attr.validators.instance_of(float) - ) + width: float = attr.ib(default=70.0, validator=attr.validators.instance_of(float)) minimumdte: int = attr.ib(default=1, validator=attr.validators.instance_of(int)) maximumdte: int = attr.ib(default=4, validator=attr.validators.instance_of(int)) openingorderloopseconds: int = attr.ib( @@ -50,7 +48,7 @@ class SpreadsByDeltaStrategy(Strategy, Component): validator=attr.validators.instance_of(dt.datetime), ) minutes_before_close: int = attr.ib( - default=10, validator=attr.validators.instance_of(int) + default=5, validator=attr.validators.instance_of(int) ) # Core Strategy Process @@ -75,9 +73,7 @@ def process_strategy(self): # If the next market session is not today, wait until 10 minutes before close if hours.start.day != now.day: - self.sleepuntil = hours.end - dt.timedelta( - minutes=self.minutes_before_close - ) + self.sleepuntil = hours.end - dt.timedelta(minutes=10) logger.info( "Markets are closed until {}. Sleeping until {}".format( hours.start, self.sleepuntil @@ -105,8 +101,10 @@ def process_pre_market(self): nextmarketsession = self.get_market_session_loop(dt.datetime.now()) # Set sleepuntil - self.sleepuntil = nextmarketsession.end - dt.timedelta( - minutes=self.minutes_before_close + self.sleepuntil = ( + nextmarketsession.end + - dt.timedelta(minutes=self.minutes_before_close) + - dt.timedelta(minutes=5) ) logger.info( @@ -133,7 +131,6 @@ def place_new_orders_loop(self) -> None: if neworder is None: return - # Place the order and check the result if self.place_order(neworder): return @@ -170,7 +167,7 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: fromdate=startdate, todate=enddate, symbol=self.underlying, - includequotes=True, + includequotes=False, optionrange="OTM", ) @@ -186,7 +183,7 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: expiration = self.get_next_expiration(chain.callexpdatemap) # If no valid expirations, exit. - if expiration is None or expiration.strikes is None: + if expiration is None: return None # Get the short strike @@ -196,7 +193,7 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: if short_strike is None: return None - long_strike = self.get_long_strike(expiration.strikes, short_strike) + long_strike = self.get_long_strike(expiration.strikes, short_strike.strike) # If no valid long strike, exit. if long_strike is None: @@ -222,7 +219,7 @@ def build_order_request( # Calculate price price = ( - short_strike.bid + short_strike.ask - long_strike.bid - long_strike.ask + short_strike.bid + short_strike.ask - (long_strike.bid + long_strike.ask) ) / 2 formattedprice = self.format_order_price(price) @@ -278,14 +275,6 @@ def build_leg_instruction(self, short_or_long: str) -> str: def build_new_order_precheck( self, account: baseRR.GetAccountResponseMessage ) -> bool: - - if self.width == float("inf"): - return True - - # Check if we have positions on already that expire today. - # 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 any( position.underlyingsymbol == self.underlying and position.expirationdate.date() != dt.date.today() @@ -401,7 +390,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 not expirations: + if expirations is None or expirations == []: logger.error("No expirations provided.") return None @@ -452,52 +441,21 @@ def get_long_strike( strikes: dict[ float, baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike ], - first_strike: baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, + short_strike: float, ) -> Union[baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, None]: """Searches an option chain for the optimal strike.""" logger.debug("get_best_strike") - best_strike = 0.0 + new_strike = short_strike - self.width - # If Max Width, find cheapest long - if self.width == float("inf"): - - if self.buy_or_sell == "BUY": - logger.error("Cannot buy a max-width spread.") - return None - - best_mid = float("inf") - - for strike, detail in strikes.items(): - mid = (detail.bid + detail.ask) / 2 - - # If the mid-price is lower, use it - if 0.00 < mid < best_mid: - best_strike = strike - best_mid = mid - # If we're selling a PUT and the mid price is the same, but the strike is higher, use it. - elif (self.put_or_call == "PUT") and ( - (mid == best_mid) and (best_strike < strike < first_strike.strike) - ): - best_strike = strike - best_mid = mid - # If we're selling a CALL and the mid price is the same, but the strike is lower, use it. - elif self.put_or_call == "CALL" and ( - (mid == best_mid) and (best_strike > strike > first_strike.strike) - ): - best_strike = strike - best_mid = mid - - # Otherwise get closest strike to the set width - else: - best_delta = 1000000.0 - new_strike = first_strike.strike - self.width + best_strike = 0.0 + best_delta = 1000000.0 - for strike in strikes: - delta = strike - new_strike - if abs(delta) < best_delta: - best_strike = strike - best_delta = abs(delta) + for strike in strikes: + delta = strike - new_strike + if abs(delta) < best_delta: + best_strike = strike + best_delta = abs(delta) # Return the strike return strikes[best_strike] @@ -511,38 +469,30 @@ def calculate_order_quantity( """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 = abs(shortstrike - longstrike) * 100 + # Calculate max buying power to use balance_to_risk = account_balance.liquidationvalue * float( self.portfolioallocationpercent ) - # Calculate max loss per contract - if self.width == float("inf"): - # Calculate max loss per contract - max_loss = shortstrike * 100 * 0.2 - - trading_power = account_balance.buyingpower - ( - account_balance.liquidationvalue - balance_to_risk - ) - - else: - max_loss = abs(shortstrike - longstrike) * 100 - - trading_power = min(balance_to_risk, account_balance.buyingpower) + remainingbalance = account_balance.buyingpower + trading_power = min(balance_to_risk, remainingbalance) # Calculate trade size trade_size = trading_power // max_loss # Log Values logger.info( - "Short Strike: {} Long Strike: {} BuyingPower: {} LiquidationValue: {} MaxLoss: {} BalanceToRisk: {} TradingPower: {} TradeSize: {} ".format( + "Short Strike: {} Long Strike: {} BuyingPower: {} LiquidationValue: {} MaxLoss: {} BalanceToRisk: {} RemainingBalance: {} TradeSize: {} ".format( shortstrike, longstrike, account_balance.buyingpower, account_balance.liquidationvalue, max_loss, balance_to_risk, - trading_power, + remainingbalance, trade_size, ) ) From 3571ffe484517d647b38c6133456e08dbab852d7 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 9 Feb 2022 12:13:55 +0000 Subject: [PATCH 10/34] code cleanup --- looptrader/basetypes/Strategy/singlebydeltastrategy.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index 38544cb..983f96b 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -676,9 +676,7 @@ def get_best_strike( calculated_delta = details.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 From 1610e3ae9e69d8843a868c11c3350612d37b327a Mon Sep 17 00:00:00 2001 From: Sourcery AI <> Date: Wed, 9 Feb 2022 12:14:31 +0000 Subject: [PATCH 11/34] 'Refactored by Sourcery' --- looptrader/basetypes/Strategy/spreadsbydeltastrategy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py index 65c555f..7900cf6 100644 --- a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py +++ b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py @@ -390,7 +390,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 From 5b79c0420e1af028ec59c896d815ea5057e057a1 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 9 Feb 2022 17:33:08 +0000 Subject: [PATCH 12/34] code cleanup --- looptrader/basetypes/Broker/tdaBroker.py | 44 ++++++++++++------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/looptrader/basetypes/Broker/tdaBroker.py b/looptrader/basetypes/Broker/tdaBroker.py index 6235c6d..841661b 100644 --- a/looptrader/basetypes/Broker/tdaBroker.py +++ b/looptrader/basetypes/Broker/tdaBroker.py @@ -470,8 +470,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""" @@ -493,27 +493,7 @@ def translate_option_chain( 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 - ) + strikeresponse = self.Build_Option_Chain_Strike(detail) expiry.strikes[ detail.get("strikePrice", float) @@ -523,6 +503,26 @@ 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 ) -> baseModels.OrderActivity: From 3e9792819c8e96d1b6d20fb8f1b206c248ff42e0 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Mon, 14 Feb 2022 19:38:33 +0000 Subject: [PATCH 13/34] bug fixes and cleanup --- looptrader/__main__.py | 5 +- .../Strategy/singlebydeltastrategy.py | 62 ++++++++++++------- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/looptrader/__main__.py b/looptrader/__main__.py index 648e1fe..6b19b7d 100644 --- a/looptrader/__main__.py +++ b/looptrader/__main__.py @@ -34,11 +34,11 @@ target_delta=0.03, min_delta=0.01, profit_target_percent=0.83, + portfolio_allocation_percent=2.0, + offset_sold_positions=True, ) spreadstrat = SpreadsByDeltaStrategy(strategy_name="spreads") - # testspreads = SpreadsByDeltaStrategy(strategy_name="test spreads", width=float("inf"), put_or_call="CALL", targetdelta=.03, portfolioallocationpercent=2.0, minutes_before_close=390) - # Create our brokers individualbroker = TdaBroker(id="individual") irabroker = TdaBroker(id="ira") @@ -56,7 +56,6 @@ cspstrat: individualbroker, nakedcalls: individualbroker, vgshstrat: individualbroker, - # testspreads: individualbroker, }, database=sqlitedb, notifier=telegram_bot, diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index 983f96b..9e5d649 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -56,9 +56,6 @@ 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), validator=attr.validators.instance_of(dt.timedelta), @@ -75,7 +72,7 @@ class SingleByDeltaStrategy(Strategy, Component): default=True, validator=attr.validators.instance_of(bool) ) offset_sold_positions: bool = attr.ib( - default=True, validator=attr.validators.instance_of(bool) + default=False, validator=attr.validators.instance_of(bool) ) # Core Strategy Process @@ -166,7 +163,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. @@ -183,7 +180,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. @@ -281,6 +278,8 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: return None # If we should immediate offset positions, get the second leg. + offset_strike = None + if self.offset_sold_positions: offset_strike = self.get_offsetting_strike(expiration.strikes) @@ -366,13 +365,13 @@ def build_offsetting_order( # Return Order return self.build_opening_order_request(strike, None, qty, offsetting=True) - def new_build_closing_order( + def build_closing_order( self, original_order: baseModels.Order ) -> baseRR.PlaceOrderRequestMessage: """Builds a closing order request message for a given position.""" # Build base order - order_request = self.build_base_order_request_message() + order_request = self.build_base_order_request_message(is_closing=True) # Build and append new legs for leg in original_order.legs: @@ -390,13 +389,23 @@ def new_build_closing_order( # Return request return order_request - def build_base_order_request_message(self): + def build_base_order_request_message(self, is_closing: bool = False): 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 ( + is_closing + and self.buy_or_sell == "SELL" + or not is_closing + and self.buy_or_sell != "SELL" + ): + orderrequest.order.order_type = "NET_DEBIT" + else: + orderrequest.order.order_type = "NET_CREDIT" + orderrequest.order.session = "NORMAL" orderrequest.order.legs = list[baseModels.OrderLeg]() @@ -464,7 +473,7 @@ 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) + closing_order = self.build_closing_order(new_order_request.order) self.place_order(closing_order) return @@ -567,21 +576,24 @@ 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 - for leg in latest_order.order.legs: - for leg2 in order.legs: - if leg.cusip == leg2.cusip: - leg.id = leg2.id + latest_order.order.id = order.id - # Update the DB record - create_order_req = baseRR.UpdateDatabaseOrderRequest(latest_order.order) - self.mediator.update_db_order(create_order_req) + for leg in latest_order.order.legs: + for leg2 in order.legs: + if leg.cusip == leg2.cusip: + leg.id = leg2.id + break - # If the Order's status is still open, update our flag - if latest_order.order.isActive(): - current_orders.append(latest_order.order) + # 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) return current_orders @@ -620,6 +632,10 @@ def get_next_expiration( 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 From 9bf9b9cb9c45e62ecceb0137b800f5dd238d9e4b Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Mon, 14 Feb 2022 19:50:01 +0000 Subject: [PATCH 14/34] cleanup --- looptrader/basetypes/Strategy/singlebydeltastrategy.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index 9e5d649..c8e68e8 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -270,7 +270,6 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: account.currentbalances.liquidationvalue, expiration.daystoexpiration, chain.underlyinglastprice, - chain.volatility, ) # If no valid strikes, exit. @@ -659,7 +658,6 @@ def get_best_strike( liquidation_value: float, days_to_expiration: int, underlying_last_price: float, - iv: float, ) -> Union[baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, None]: """Searches an option chain for the optimal strike.""" From b408cf8d018ab9dad7575535912510e9d52675e3 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Tue, 15 Feb 2022 17:22:35 +0000 Subject: [PATCH 15/34] bug fix for order_types --- looptrader/__main__.py | 3 +++ looptrader/basetypes/Strategy/singlebydeltastrategy.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/looptrader/__main__.py b/looptrader/__main__.py index 6b19b7d..8c404a7 100644 --- a/looptrader/__main__.py +++ b/looptrader/__main__.py @@ -21,6 +21,7 @@ vgshstrat = LongSharesStrategy( strategy_name="VGSH Core", underlying="VGSH", portfolio_allocation_percent=0.9 ) + cspstrat = SingleByDeltaStrategy( strategy_name="Puts", put_or_call="PUT", @@ -28,6 +29,7 @@ min_delta=0.03, profit_target_percent=0.7, ) + nakedcalls = SingleByDeltaStrategy( strategy_name="Calls", put_or_call="CALL", @@ -37,6 +39,7 @@ portfolio_allocation_percent=2.0, offset_sold_positions=True, ) + spreadstrat = SpreadsByDeltaStrategy(strategy_name="spreads") # Create our brokers diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index c8e68e8..258d972 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -395,7 +395,9 @@ def build_base_order_request_message(self, is_closing: bool = False): orderrequest.order.order_strategy_type = "SINGLE" orderrequest.order.duration = "GOOD_TILL_CANCEL" - if ( + if self.offset_sold_positions is False: + orderrequest.order.order_type = "LIMIT" + elif ( is_closing and self.buy_or_sell == "SELL" or not is_closing From 298899a51fb9998b52b464fa684db66b546ce42d Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Wed, 16 Feb 2022 22:32:53 +0000 Subject: [PATCH 16/34] Don't close offsetting legs and reuse when we can. --- .../basetypes/Database/abstractDatabase.py | 8 +++ looptrader/basetypes/Database/ormDatabase.py | 39 +++++++++- .../basetypes/Mediator/abstractMediator.py | 7 ++ looptrader/basetypes/Mediator/botMediator.py | 18 +++-- looptrader/basetypes/Mediator/reqRespTypes.py | 14 ++++ .../Strategy/singlebydeltastrategy.py | 71 +++++++++++++++++-- 6 files changed, 145 insertions(+), 12 deletions(-) diff --git a/looptrader/basetypes/Database/abstractDatabase.py b/looptrader/basetypes/Database/abstractDatabase.py index 779655a..2e555bc 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_first_offset_leg( + self, request: baseRR.ReadFirstDatabaseOffsetLegRequest + ) -> Union[baseRR.ReadFirstDatabaseOffsetLegResponse, None]: + raise NotImplementedError( + "Each database must implement the 'read_first_offset_leg' method." + ) diff --git a/looptrader/basetypes/Database/ormDatabase.py b/looptrader/basetypes/Database/ormDatabase.py index b55ae32..6f5737a 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_first_offset_leg( + self, request: baseRR.ReadFirstDatabaseOffsetLegRequest + ) -> baseRR.ReadFirstDatabaseOffsetLegResponse: + # 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.ReadFirstDatabaseOffsetLegResponse() + + 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") + .first() + ) + + session.commit() + + response.offset_leg = 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..72c9612 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_first_offset_leg( + self, request: baseRR.ReadFirstDatabaseOffsetLegRequest + ) -> Union[baseRR.ReadFirstDatabaseOffsetLegResponse, None]: + raise NotImplementedError( + "Each mediator must implement the 'read_first_offset_leg' method." + ) diff --git a/looptrader/basetypes/Mediator/botMediator.py b/looptrader/basetypes/Mediator/botMediator.py index 2307247..6ba1a37 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_first_offset_leg( + self, request: baseRR.ReadFirstDatabaseOffsetLegRequest + ) -> Union[baseRR.ReadFirstDatabaseOffsetLegResponse, None]: + return self.database.read_first_offset_leg(request) diff --git a/looptrader/basetypes/Mediator/reqRespTypes.py b/looptrader/basetypes/Mediator/reqRespTypes.py index 4f2a9aa..f772c79 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 ReadFirstDatabaseOffsetLegRequest: + 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 ReadFirstDatabaseOffsetLegResponse: + offset_leg: base.OrderLeg = attr.ib( + validator=attr.validators.instance_of(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/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index 258d972..f417f24 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -322,12 +322,21 @@ def build_opening_order_request( # If we have an offset_strike... if offset_strike is not None: - # Build Long Leg and append - long_leg = self.build_leg(offset_strike.symbol, qty, "BUY", True) - order_request.order.legs.append(long_leg) + # Check if we already have a Long position in place. + long_offset = self.get_current_offset(first_leg.expiration_date) - # Subtract the offset, if it exists - price = price - (offset_strike.bid + offset_strike.ask) / 2 + # If none exists or quantity doesn't match, build the offset leg + if long_offset is None or long_offset.quantity != first_leg.quantity: + # If we have an offset and the offset qty < new qty, then qty = the delta between current and new qty + if long_offset is not None and (long_offset.quantity < qty): + qty -= long_offset.quantity + + # Build Long Leg and append + long_leg = self.build_leg(offset_strike.symbol, qty, "BUY", True) + order_request.order.legs.append(long_leg) + + # Subtract the offset, if it exists + price = price - (offset_strike.bid + offset_strike.ask) / 2 # Set the price order_request.order.price = helpers.format_order_price(price) @@ -374,7 +383,11 @@ def build_closing_order( # Build and append new legs for leg in original_order.legs: - instruction = "BUY" if leg.instruction == "SELL_TO_OPEN" else "SELL" + instruction = self.get_closing_order_instruction(leg.instruction) + + if instruction is None: + break + new_leg = self.build_leg( leg.symbol, leg.quantity, instruction, opening=False ) @@ -388,6 +401,27 @@ def build_closing_order( # Return request return order_request + 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 build_base_order_request_message(self, is_closing: bool = False): orderrequest = baseRR.PlaceOrderRequestMessage() orderrequest.order = baseModels.Order() @@ -598,6 +632,31 @@ def get_current_orders(self) -> list[baseModels.Order]: return current_orders + def get_current_offset( + self, expiration: dt.date + ) -> Union[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. + """ + # Read DB Orders + open_offset_request = baseRR.ReadFirstDatabaseOffsetLegRequest( + self.strategy_id, + self.put_or_call, + dt.datetime.combine(expiration, dt.time(0, 0)), + ) + open_offset = self.mediator.read_first_offset_leg(open_offset_request) + + if open_offset is None or open_offset.offset_leg is None: + logger.info("No open offset exist.") + return None + + return open_offset.offset_leg + #################### ### Option Chain ### #################### From 129384d830feca27b0ca814d98a8168212e32ad5 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Fri, 18 Feb 2022 06:50:33 +0000 Subject: [PATCH 17/34] Fixing logic to handle partial fills --- .../basetypes/Database/abstractDatabase.py | 8 +-- looptrader/basetypes/Database/ormDatabase.py | 12 ++-- .../basetypes/Mediator/abstractMediator.py | 8 +-- looptrader/basetypes/Mediator/botMediator.py | 8 +-- looptrader/basetypes/Mediator/reqRespTypes.py | 8 +-- .../Strategy/singlebydeltastrategy.py | 72 ++++++++++++++----- 6 files changed, 77 insertions(+), 39 deletions(-) diff --git a/looptrader/basetypes/Database/abstractDatabase.py b/looptrader/basetypes/Database/abstractDatabase.py index 2e555bc..49ce311 100644 --- a/looptrader/basetypes/Database/abstractDatabase.py +++ b/looptrader/basetypes/Database/abstractDatabase.py @@ -49,9 +49,9 @@ def read_active_orders( ) @abc.abstractmethod - def read_first_offset_leg( - self, request: baseRR.ReadFirstDatabaseOffsetLegRequest - ) -> Union[baseRR.ReadFirstDatabaseOffsetLegResponse, None]: + def read_offset_legs_by_expiration( + self, request: baseRR.ReadOffsetLegsByExpirationRequest + ) -> Union[baseRR.ReadOffsetLegsByExpirationResponse, None]: raise NotImplementedError( - "Each database must implement the 'read_first_offset_leg' method." + "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 6f5737a..b208805 100644 --- a/looptrader/basetypes/Database/ormDatabase.py +++ b/looptrader/basetypes/Database/ormDatabase.py @@ -336,9 +336,9 @@ def read_first_strategy_by_name( return response - def read_first_offset_leg( - self, request: baseRR.ReadFirstDatabaseOffsetLegRequest - ) -> baseRR.ReadFirstDatabaseOffsetLegResponse: + 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 @@ -346,7 +346,7 @@ def read_first_offset_leg( session = DBSession(expire_on_commit=False) # Build Response - response = baseRR.ReadFirstDatabaseOffsetLegResponse() + response = baseRR.ReadOffsetLegsByExpirationResponse() try: result = ( @@ -358,12 +358,12 @@ def read_first_offset_leg( .filter(baseModels.OrderLeg.expiration_date == request.expiration) .filter(baseModels.OrderLeg.put_call == request.put_or_call) .filter(baseModels.OrderLeg.instruction == "BUY_TO_OPEN") - .first() + .all() ) session.commit() - response.offset_leg = result + response.offset_legs = result except Exception as e: print(e) session.rollback() diff --git a/looptrader/basetypes/Mediator/abstractMediator.py b/looptrader/basetypes/Mediator/abstractMediator.py index 72c9612..c3b97b7 100644 --- a/looptrader/basetypes/Mediator/abstractMediator.py +++ b/looptrader/basetypes/Mediator/abstractMediator.py @@ -132,9 +132,9 @@ def read_active_orders( "Each mediator must implement the 'read_open_orders' method." ) - def read_first_offset_leg( - self, request: baseRR.ReadFirstDatabaseOffsetLegRequest - ) -> Union[baseRR.ReadFirstDatabaseOffsetLegResponse, None]: + def read_offset_legs_by_expiration( + self, request: baseRR.ReadOffsetLegsByExpirationRequest + ) -> Union[baseRR.ReadOffsetLegsByExpirationResponse, None]: raise NotImplementedError( - "Each mediator must implement the 'read_first_offset_leg' method." + "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 6ba1a37..de7a801 100644 --- a/looptrader/basetypes/Mediator/botMediator.py +++ b/looptrader/basetypes/Mediator/botMediator.py @@ -260,7 +260,7 @@ def read_active_orders( ) -> Union[baseRR.ReadOpenDatabaseOrdersResponse, None]: return self.database.read_active_orders(request) - def read_first_offset_leg( - self, request: baseRR.ReadFirstDatabaseOffsetLegRequest - ) -> Union[baseRR.ReadFirstDatabaseOffsetLegResponse, None]: - return self.database.read_first_offset_leg(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 f772c79..fe3cbde 100644 --- a/looptrader/basetypes/Mediator/reqRespTypes.py +++ b/looptrader/basetypes/Mediator/reqRespTypes.py @@ -335,16 +335,16 @@ class ReadOpenDatabaseOrdersResponse: @attr.s(auto_attribs=True) -class ReadFirstDatabaseOffsetLegRequest: +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 ReadFirstDatabaseOffsetLegResponse: - offset_leg: base.OrderLeg = attr.ib( - validator=attr.validators.instance_of(base.OrderLeg) +class ReadOffsetLegsByExpirationResponse: + offset_legs: list[base.OrderLeg] = attr.ib( + validator=attr.validators.instance_of(list[base.OrderLeg]) ) diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index f417f24..a162cbb 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 @@ -313,7 +314,11 @@ def build_opening_order_request( # Build the first leg and append first_leg = self.build_leg( - strike.symbol, qty, "BUY" if offsetting else self.buy_or_sell, True + strike.symbol, + strike.description, + qty, + "BUY" if offsetting else self.buy_or_sell, + True, ) order_request.order.legs.append(first_leg) @@ -323,20 +328,38 @@ def build_opening_order_request( # If we have an offset_strike... if offset_strike is not None: # Check if we already have a Long position in place. - long_offset = self.get_current_offset(first_leg.expiration_date) - - # If none exists or quantity doesn't match, build the offset leg - if long_offset is None or long_offset.quantity != first_leg.quantity: - # If we have an offset and the offset qty < new qty, then qty = the delta between current and new qty - if long_offset is not None and (long_offset.quantity < qty): - qty -= long_offset.quantity + long_offsets = self.get_current_offsets(first_leg.expiration_date) + # If no longs, build one. + if long_offsets is None: # Build Long Leg and append - long_leg = self.build_leg(offset_strike.symbol, qty, "BUY", True) + long_leg = self.build_leg( + offset_strike.symbol, offset_strike.description, qty, "BUY", True + ) order_request.order.legs.append(long_leg) # Subtract the offset, if it exists price = price - (offset_strike.bid + offset_strike.ask) / 2 + else: + # If there are longs, get the quantity + qty = sum(leg.quantity for leg in long_offsets) + + # If we have less than we need, determine the delta, buld the additonal leg(s) + if qty < first_leg.quantity: + qty -= first_leg.quantity + + # Build Long Leg and append + long_leg = self.build_leg( + offset_strike.symbol, + offset_strike.description, + qty, + "BUY", + True, + ) + order_request.order.legs.append(long_leg) + + # Subtract the offset, if it exists + price = price - (offset_strike.bid + offset_strike.ask) / 2 # Set the price order_request.order.price = helpers.format_order_price(price) @@ -389,7 +412,7 @@ def build_closing_order( break new_leg = self.build_leg( - leg.symbol, leg.quantity, instruction, opening=False + leg.symbol, leg.description, leg.quantity, instruction, opening=False ) order_request.order.legs.append(new_leg) @@ -447,10 +470,16 @@ def build_base_order_request_message(self, is_closing: bool = False): return orderrequest def build_leg( - self, symbol: str, quantity: int, buy_or_sell: str, opening: bool + self, + symbol: str, + description: str, + quantity: int, + buy_or_sell: str, + opening: bool, ) -> baseModels.OrderLeg: leg = baseModels.OrderLeg() leg.symbol = symbol + leg.description = description leg.asset_type = "OPTION" leg.quantity = quantity leg.position_effect = "OPENING" if opening else "CLOSING" @@ -467,6 +496,12 @@ def build_leg( leg.instruction = instruction + 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 ##################### @@ -632,9 +667,9 @@ def get_current_orders(self) -> list[baseModels.Order]: return current_orders - def get_current_offset( + def get_current_offsets( self, expiration: dt.date - ) -> Union[baseModels.OrderLeg, None]: + ) -> Union[list[baseModels.OrderLeg], None]: """Returns the first offsetting leg found in the DB for the given expiration date. Args: @@ -643,19 +678,22 @@ def get_current_offset( Returns: Union[baseModels.OrderLeg,None]: The leg from the DB, if found. """ + if expiration is None: + raise BaseException("No Expiration Date Provided for Offset Lookup") + # Read DB Orders - open_offset_request = baseRR.ReadFirstDatabaseOffsetLegRequest( + open_offset_request = baseRR.ReadOffsetLegsByExpirationRequest( self.strategy_id, self.put_or_call, dt.datetime.combine(expiration, dt.time(0, 0)), ) - open_offset = self.mediator.read_first_offset_leg(open_offset_request) + open_offsets = self.mediator.read_offset_legs_by_expiration(open_offset_request) - if open_offset is None or open_offset.offset_leg is None: + if open_offsets is None or open_offsets.offset_legs == []: logger.info("No open offset exist.") return None - return open_offset.offset_leg + return open_offsets.offset_legs #################### ### Option Chain ### From 71bc4768bd56927fe515e867867bd17598df2bd7 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Fri, 18 Feb 2022 10:35:17 +0000 Subject: [PATCH 18/34] Cleanup and fixes --- .../Strategy/singlebydeltastrategy.py | 143 ++++++++++-------- 1 file changed, 83 insertions(+), 60 deletions(-) diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index a162cbb..afb996d 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -243,10 +243,11 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, 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_actual_buying_power(account) + + # Get default option chain + chainrequest = self.build_option_chain_request() chain = self.mediator.get_option_chain(chainrequest) @@ -254,9 +255,6 @@ 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 expiration = self.get_next_expiration(chain) @@ -277,31 +275,40 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: if strike is None: return None - # If we should immediate offset positions, get the second leg. + # Calculate Quantity + quantity = self.calculate_order_quantity( + strike.strike, availbp, account.currentbalances.liquidationvalue + ) + offset_strike = None + offset_qty = 0 + # If we should immediately offset positions, decide how many we need. if self.offset_sold_positions: - offset_strike = self.get_offsetting_strike(expiration.strikes) + offset_qty = self.calculate_offset_leg_quantity( + quantity, expiration.expirationdate + ) - # If no valid strikes, exit. - if offset_strike is None: - return None + if offset_qty > 0: + offset_strike = self.get_offsetting_strike(expiration.strikes) - # Calculate Quantity - qty = self.calculate_order_quantity( - strike.strike, availbp, account.currentbalances.liquidationvalue - ) + # If we should have an offset, but don't find one, exit. + if offset_strike is None: + raise BaseException("No offset strike found when expected.") # Return Order - return self.build_opening_order_request(strike, offset_strike, qty=qty) + return self.build_opening_order_request( + strike, quantity, offset_strike, offset_qty + ) def build_opening_order_request( self, strike: baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, + qty: int, offset_strike: Union[ baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, None - ], - qty: int, + ] = None, + offset_qty: int = 0, offsetting: bool = False, ) -> Union[baseRR.PlaceOrderRequestMessage, None]: @@ -312,7 +319,7 @@ def build_opening_order_request( # Build Base Order order_request = self.build_base_order_request_message() - # Build the first leg and append + # Build the first leg first_leg = self.build_leg( strike.symbol, strike.description, @@ -320,50 +327,33 @@ def build_opening_order_request( "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 have an offset_strike... - if offset_strike is not None: - # Check if we already have a Long position in place. - long_offsets = self.get_current_offsets(first_leg.expiration_date) + # If we have an offset_strike and quantity > 0... + if ( + self.buy_or_sell == "SELL" + and not offsetting + and offset_strike is not None + and offset_qty > 0 + ): + # 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) - # If no longs, build one. - if long_offsets is None: - # Build Long Leg and append - long_leg = self.build_leg( - offset_strike.symbol, offset_strike.description, qty, "BUY", True - ) - order_request.order.legs.append(long_leg) + # Recalculate the price + price = price - (offset_strike.bid + offset_strike.ask) / 2 - # Subtract the offset, if it exists - price = price - (offset_strike.bid + offset_strike.ask) / 2 - else: - # If there are longs, get the quantity - qty = sum(leg.quantity for leg in long_offsets) - - # If we have less than we need, determine the delta, buld the additonal leg(s) - if qty < first_leg.quantity: - qty -= first_leg.quantity - - # Build Long Leg and append - long_leg = self.build_leg( - offset_strike.symbol, - offset_strike.description, - qty, - "BUY", - True, - ) - order_request.order.legs.append(long_leg) - - # Subtract the offset, if it exists - price = price - (offset_strike.bid + offset_strike.ask) / 2 - - # Set the price + # Format the price order_request.order.price = helpers.format_order_price(price) + # Return the request message return order_request def build_offsetting_order( @@ -394,7 +384,7 @@ def build_offsetting_order( return None # Return Order - return self.build_opening_order_request(strike, None, qty, offsetting=True) + return self.build_opening_order_request(strike, qty, offsetting=True) def build_closing_order( self, original_order: baseModels.Order @@ -699,13 +689,21 @@ def get_current_offsets( ### 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, @@ -932,7 +930,7 @@ def calculate_position_buying_power( ) def calculate_order_quantity( - self, strike: float, buyingpower: float, liquidationvalue: float + self, strike: float, buyingpower: float, liquidation_value: float ) -> int: """Calculates the number of positions to open for a given account and strike.""" logger.debug("calculate_order_quantity") @@ -941,12 +939,37 @@ def calculate_order_quantity( max_loss = strike * 100 * float(self.max_loss_calc_percent) # Calculate max buying power to use - balance_to_risk = liquidationvalue * float(self.portfolio_allocation_percent) + balance_to_risk = liquidation_value * float(self.portfolio_allocation_percent) - remainingbalance = buyingpower - (liquidationvalue - balance_to_risk) + remainingbalance = buyingpower - (liquidation_value - balance_to_risk) # Calculate trade size trade_size = remainingbalance // max_loss # Return quantity return int(trade_size) + + 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 + qty = sum(leg.quantity for leg in long_offsets) + + # Return either the difference between our target qty and actual qty, or 0, whichever is larger + return max(target_qty - qty, 0) From f6ceedd9ef3a1f97227ac6d4271d188094ed1a33 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Fri, 18 Feb 2022 10:50:15 +0000 Subject: [PATCH 19/34] more cleanup --- looptrader/basetypes/Broker/tdaBroker.py | 48 +++++++++++++----------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/looptrader/basetypes/Broker/tdaBroker.py b/looptrader/basetypes/Broker/tdaBroker.py index 841661b..3a0907f 100644 --- a/looptrader/basetypes/Broker/tdaBroker.py +++ b/looptrader/basetypes/Broker/tdaBroker.py @@ -662,30 +662,34 @@ 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{1,2} \d{4})", instrument.get("description") - ) - if match is not None: - accountposition.expirationdate = dtime.datetime.strptime( - match.group(), "%b %d %Y" - ) - - 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) + return accountposition - strikeprice = re.search(r"(?<=[PC])\d\w+", instrument.get("symbol", str)) + @staticmethod + def translate_account_position_instrument(accountposition, instrument): + desc = instrument.get("description") - if strikeprice is None and accountposition.assettype == "OPTION": - logger.error( - "No strike price found for {}".format(instrument.get("symbol", str)) + if desc is not None: + match = re.search( + r"([A-Z]{1}[a-z]{2} \d{1,2} \d{4})", instrument.get("description") + ) + if match is not None: + accountposition.expirationdate = dtime.datetime.strptime( + match.group(), "%b %d %Y" ) - elif strikeprice is not None: - accountposition.strikeprice = float(strikeprice.group()) - 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) + + strikeprice = re.search(r"(?<=[PC])\d\w+", instrument.get("symbol", str)) + + 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()) From 29704939e4373dc48b1f2ef9bdf6f27a3e9ceba1 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Fri, 18 Feb 2022 10:59:06 +0000 Subject: [PATCH 20/34] cleanups --- looptrader/basetypes/Broker/tdaBroker.py | 44 +++++++++++++----------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/looptrader/basetypes/Broker/tdaBroker.py b/looptrader/basetypes/Broker/tdaBroker.py index 3a0907f..9b238b7 100644 --- a/looptrader/basetypes/Broker/tdaBroker.py +++ b/looptrader/basetypes/Broker/tdaBroker.py @@ -594,6 +594,30 @@ 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 = self.translate_base_account_order(order) + + legs = order.get("orderLegCollection") + if legs is not None: + for leg in legs: + # Build Leg + accountorderleg = self.translate_account_order_leg(leg) + accountorderleg.order_id = accountorder.order_id + # Append Leg + accountorder.legs.append(accountorderleg) + + activities = order.get("orderActivityCollection") + if activities is not None: + for activity in activities: + # Build Leg + accountorderactivity = self.translate_account_order_activity(activity) + accountorderactivity.order_id = accountorder.order_id + + # Append Leg + accountorder.activities.append(accountorderactivity) + + return accountorder + + def translate_base_account_order(self, order): accountorder = baseModels.Order() accountorder.order_strategy_type = order.get("complexOrderStrategyType", "") accountorder.order_type = order.get("orderType", "") @@ -619,26 +643,6 @@ def translate_account_order(self, order: dict) -> baseModels.Order: accountorder.cancelable = order.get("cancelable", False) accountorder.editable = order.get("editable", False) accountorder.legs = [] - - legs = order.get("orderLegCollection") - if legs is not None: - for leg in legs: - # Build Leg - accountorderleg = self.translate_account_order_leg(leg) - accountorderleg.order_id = accountorder.order_id - # Append Leg - accountorder.legs.append(accountorderleg) - - activities = order.get("orderActivityCollection") - if activities is not None: - for activity in activities: - # Build Leg - accountorderactivity = self.translate_account_order_activity(activity) - accountorderactivity.order_id = accountorder.order_id - - # Append Leg - accountorder.activities.append(accountorderactivity) - return accountorder @staticmethod From f32b3c53caf7482fb300f38c53a0aa2ed9412caf Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Fri, 18 Feb 2022 11:00:48 +0000 Subject: [PATCH 21/34] cleanup --- looptrader/basetypes/Broker/tdaBroker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/looptrader/basetypes/Broker/tdaBroker.py b/looptrader/basetypes/Broker/tdaBroker.py index 9b238b7..8a749dd 100644 --- a/looptrader/basetypes/Broker/tdaBroker.py +++ b/looptrader/basetypes/Broker/tdaBroker.py @@ -617,7 +617,8 @@ def translate_account_order(self, order: dict) -> baseModels.Order: return accountorder - def translate_base_account_order(self, order): + @staticmethod + def translate_base_account_order(order) -> baseModels.Order: accountorder = baseModels.Order() accountorder.order_strategy_type = order.get("complexOrderStrategyType", "") accountorder.order_type = order.get("orderType", "") From 4340f6f5c78b2bc1e2a5d50c7c10429385c852f8 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Tue, 22 Feb 2022 14:41:50 +0000 Subject: [PATCH 22/34] Implemented dynamic PT and bug fixes --- looptrader/__main__.py | 4 +- .../Strategy/singlebydeltastrategy.py | 88 ++++++++++++------- 2 files changed, 57 insertions(+), 35 deletions(-) diff --git a/looptrader/__main__.py b/looptrader/__main__.py index 8c404a7..abaef0e 100644 --- a/looptrader/__main__.py +++ b/looptrader/__main__.py @@ -27,7 +27,7 @@ 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), ) nakedcalls = SingleByDeltaStrategy( @@ -40,7 +40,7 @@ offset_sold_positions=True, ) - spreadstrat = SpreadsByDeltaStrategy(strategy_name="spreads") + spreadstrat = SpreadsByDeltaStrategy(strategy_name="spreads", targetdelta=-0.07) # Create our brokers individualbroker = TdaBroker(id="individual") diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index afb996d..862efe7 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -43,9 +43,7 @@ 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) - ) + profit_target_percent: Union[float, tuple] = attr.ib(default=0.7) max_loss_calc_percent: float = attr.ib( default=0.2, validator=attr.validators.instance_of(float) ) @@ -129,15 +127,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 ### @@ -145,7 +140,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 ### @@ -225,7 +219,6 @@ def process_after_market(self): market = self.get_next_market_hours() self.sleep_until_market_open(market.start) - return ###################### ### Order Builders ### @@ -294,7 +287,7 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: # If we should have an offset, but don't find one, exit. if offset_strike is None: - raise BaseException("No offset strike found when expected.") + raise RuntimeError("No offset strike found when expected.") # Return Order return self.build_opening_order_request( @@ -316,8 +309,11 @@ def build_opening_order_request( if qty is None or qty <= 0: return None - # Build Base Order - order_request = self.build_base_order_request_message() + # 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( @@ -390,6 +386,7 @@ def build_closing_order( self, original_order: baseModels.Order ) -> baseRR.PlaceOrderRequestMessage: """Builds a closing order request message for a given position.""" + original_strike = 0.0 # Build base order order_request = self.build_base_order_request_message(is_closing=True) @@ -401,14 +398,43 @@ def build_closing_order( if instruction is None: break + # Get the Strike + match = re.search(r"([0-9])+$", leg.symbol) + if match is not None: + original_strike = float(match.group()) + + # 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) - # Calculate and enter price + # If it is a float, use the entered value + if isinstance(self.profit_target_percent, float): + pt = 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) + + if current_quote is not None: + current_price = current_quote.instruments[0].lastPrice + + # Calculate opening position %OTM + percent_otm = abs((current_price - original_strike) / 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]) + + # Set and format the closing price order_request.order.price = helpers.format_order_price( - original_order.price * (1 - float(self.profit_target_percent)) + original_order.price * (1 - pt) ) # Return request @@ -435,14 +461,16 @@ def get_closing_order_instruction( else: return None - def build_base_order_request_message(self, is_closing: bool = False): + def build_base_order_request_message( + self, is_closing: bool = False, is_single: bool = True + ): 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" - if self.offset_sold_positions is False: + if self.offset_sold_positions is False or is_single: orderrequest.order.order_type = "LIMIT" elif ( is_closing @@ -450,7 +478,7 @@ def build_base_order_request_message(self, is_closing: bool = False): or not is_closing and self.buy_or_sell != "SELL" ): - orderrequest.order.order_type = "NET_DEBIT" + orderrequest.order.order_type = "LIMIT" else: orderrequest.order.order_type = "NET_CREDIT" @@ -520,8 +548,6 @@ def place_offsetting_order_loop(self, qty: int) -> None: # Otherwise, try again self.place_offsetting_order_loop(qty) - return - def place_new_orders_loop(self) -> None: """Looping Logic for placing new orders""" # Build Order @@ -540,8 +566,6 @@ def place_new_orders_loop(self) -> None: # 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 @@ -669,7 +693,7 @@ def get_current_offsets( Union[baseModels.OrderLeg,None]: The leg from the DB, if found. """ if expiration is None: - raise BaseException("No Expiration Date Provided for Offset Lookup") + raise RuntimeError("No Expiration Date Provided for Offset Lookup") # Read DB Orders open_offset_request = baseRR.ReadOffsetLegsByExpirationRequest( @@ -823,20 +847,18 @@ def get_offsetting_strike( mid = (detail.bid + detail.ask) / 2 # If the mid-price is lower, use it - if 0.00 < mid < best_mid: - best_strike = strike - best_mid = mid - # If we're selling a PUT and the mid price is the same, but the strike is higher, use it. - elif (self.put_or_call == "PUT") and ( - (mid == best_mid) and (best_strike < strike) - ): - best_strike = strike - best_mid = mid - # If we're selling a CALL and the mid price is the same, but the strike is lower, use it. - elif self.put_or_call == "CALL" and ( - (mid == best_mid) and (best_strike > strike) + if ( + (0.00 < mid < best_mid) + or ( + (self.put_or_call == "PUT") + and ((mid == best_mid) and (best_strike < strike)) + ) + or ( + self.put_or_call == "CALL" + and ((mid == best_mid) and (best_strike > strike)) + ) ): best_strike = strike best_mid = mid From 7474934c6a9293574906990ac33980b32dbb8042 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Fri, 25 Feb 2022 11:37:28 +0000 Subject: [PATCH 23/34] cleanup and bug fixes --- looptrader/basetypes/Broker/tdaBroker.py | 89 +++++++++++-------- .../basetypes/Notifier/telegramnotifier.py | 4 +- .../Strategy/singlebydeltastrategy.py | 21 ++++- 3 files changed, 75 insertions(+), 39 deletions(-) diff --git a/looptrader/basetypes/Broker/tdaBroker.py b/looptrader/basetypes/Broker/tdaBroker.py index 8a749dd..0a50ab2 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 # @@ -130,6 +131,9 @@ def get_order( if attempt == self.maxretries - 1: return None + if order is None: + raise ValueError("No Order to Translate") + response = baseRR.GetOrderResponseMessage() response.order = self.translate_account_order(order) @@ -349,11 +353,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) @@ -524,20 +526,20 @@ def Build_Option_Chain_Strike(detail: dict): 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 @@ -558,7 +560,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 @@ -594,6 +596,9 @@ 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""" + if order is None: + raise ValueError("No Order Passed In") + accountorder = self.translate_base_account_order(order) legs = order.get("orderLegCollection") @@ -619,6 +624,9 @@ def translate_account_order(self, order: dict) -> baseModels.Order: @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", "") @@ -632,14 +640,12 @@ def translate_base_account_order(order) -> baseModels.Order: 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" + order.get("enteredTime", dtime.datetime), DT_REGEX ) close = order.get("closeTime") if close is not None: - accountorder.close_time = dtime.datetime.strptime( - close, "%Y-%m-%dT%H:%M:%S%z" - ) + 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) @@ -672,29 +678,42 @@ def translate_account_position(position: dict): return accountposition @staticmethod - def translate_account_position_instrument(accountposition, instrument): - desc = instrument.get("description") + 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) - if desc is not None: - match = re.search( - r"([A-Z]{1}[a-z]{2} \d{1,2} \d{4})", instrument.get("description") - ) - if match is not None: + 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( - match.group(), "%b %d %Y" + exp_match.group(), "%m%d%y" ) - accountposition.assettype = instrument.get("assetType", str) + strike_match = re.search(r"(?<=[PC])\d\w+", symbol) + + if strike_match is not None: + accountposition.strikeprice = float(strike_match.group()) + elif accountposition.assettype == "OPTION": + logger.error("No strike price found for {}".format(symbol)) + + # Map the other fields accountposition.description = instrument.get("description", str) accountposition.putcall = instrument.get("putCall", str) - accountposition.symbol = instrument.get("symbol", str) accountposition.underlyingsymbol = instrument.get("underlyingSymbol", str) - - strikeprice = re.search(r"(?<=[PC])\d\w+", instrument.get("symbol", str)) - - 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()) diff --git a/looptrader/basetypes/Notifier/telegramnotifier.py b/looptrader/basetypes/Notifier/telegramnotifier.py index ecb0687..f2d2e84 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, ) diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index 862efe7..575aac8 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -990,8 +990,23 @@ def calculate_offset_leg_quantity( if long_offsets is None: return target_qty + # 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) + + open_qty = 0 + + if open_orders is not None: + 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_qty += leg.quantity + # If we do have offsets, sum up the quantity - qty = sum(leg.quantity for leg in long_offsets) + offset_qty = sum(leg.quantity for leg in long_offsets) - # Return either the difference between our target qty and actual qty, or 0, whichever is larger - return max(target_qty - qty, 0) + # Return either the difference between our target qty and actual qty, or 0, whichever is larger + return max(target_qty - (offset_qty - open_qty), 0) From 7490579ae41753a16133bc6f9673bba84e3e78c1 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Tue, 1 Mar 2022 16:03:38 +0000 Subject: [PATCH 24/34] cleanup --- looptrader/basetypes/Strategy/singlebydeltastrategy.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index 575aac8..dac9aec 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -384,7 +384,7 @@ def build_offsetting_order( def build_closing_order( self, original_order: baseModels.Order - ) -> baseRR.PlaceOrderRequestMessage: + ) -> Union[None, baseRR.PlaceOrderRequestMessage]: """Builds a closing order request message for a given position.""" original_strike = 0.0 @@ -411,6 +411,8 @@ def build_closing_order( # 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 pt = 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): @@ -432,6 +434,8 @@ def build_closing_order( else: pt = float(self.profit_target_percent[2]) + logger.info(f"OTM: {percent_otm*100}%, PT: {pt*100}%") + # Set and format the closing price order_request.order.price = helpers.format_order_price( original_order.price * (1 - pt) @@ -560,7 +564,8 @@ 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.build_closing_order(new_order_request.order) - self.place_order(closing_order) + if closing_order is not None: + self.place_order(closing_order) return # Otherwise, try again From 979e687d8cda5ed75e22c87e5be3c79f0a5f2bbc Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Thu, 3 Mar 2022 14:04:20 +0000 Subject: [PATCH 25/34] adding portfolio check to offset logic --- .../basetypes/Notifier/telegramnotifier.py | 2 +- .../Strategy/singlebydeltastrategy.py | 78 ++++++++++++++----- 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/looptrader/basetypes/Notifier/telegramnotifier.py b/looptrader/basetypes/Notifier/telegramnotifier.py index f2d2e84..3870f0a 100644 --- a/looptrader/basetypes/Notifier/telegramnotifier.py +++ b/looptrader/basetypes/Notifier/telegramnotifier.py @@ -405,7 +405,7 @@ def reply_text( parsemode: Union[DefaultValue[str], str, None], ): """Wrapper method to send reply texts""" - if message is None: + if message is None or "": return try: diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index dac9aec..516c705 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -188,7 +188,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() @@ -283,7 +283,9 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: ) if offset_qty > 0: - offset_strike = self.get_offsetting_strike(expiration.strikes) + 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: @@ -353,7 +355,7 @@ def build_opening_order_request( return order_request 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") @@ -372,15 +374,29 @@ def build_offsetting_order( 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 = self.get_strike_from_symbol(order.legs[0].symbol) + # 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, offsetting=True) + return self.build_opening_order_request(strike, order.quantity, offsetting=True) def build_closing_order( self, original_order: baseModels.Order @@ -399,9 +415,7 @@ def build_closing_order( break # Get the Strike - match = re.search(r"([0-9])+$", leg.symbol) - if match is not None: - original_strike = float(match.group()) + original_strike = self.get_strike_from_symbol(leg.symbol) # Build the new leg and append it new_leg = self.build_leg( @@ -537,9 +551,9 @@ def cancel_order(self, order_id: int): # 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: @@ -550,7 +564,7 @@ def place_offsetting_order_loop(self, qty: int) -> None: return # Otherwise, try again - self.place_offsetting_order_loop(qty) + self.place_offsetting_order_loop(order) def place_new_orders_loop(self) -> None: """Looping Logic for placing new orders""" @@ -714,6 +728,14 @@ def get_current_offsets( return open_offsets.offset_legs + def get_strike_from_symbol(self, symbol: str): + match = re.search(r"([0-9])+$", symbol) + + if match is not None: + original_strike = float(match.group()) + + return original_strike + #################### ### Option Chain ### #################### @@ -836,6 +858,9 @@ def get_offsetting_strike( 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") @@ -844,6 +869,13 @@ def get_offsetting_strike( logger.error("Cannot buy a max-width spread.") return None + # Get Buying Power + buying_power = self.calculate_actual_buying_power(account) + + # Determine max spread for the available buying power. + max_strike_width = buying_power / strikes[0].multiplier / quantity + + # Initialize values best_mid = float("inf") best_strike = 0.0 @@ -851,20 +883,24 @@ def get_offsetting_strike( # 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 ( - (0.00 < mid < best_mid) - or ( - (self.put_or_call == "PUT") - and ((mid == best_mid) and (best_strike < strike)) - ) - or ( - self.put_or_call == "CALL" - and ((mid == best_mid) and (best_strike > strike)) + if good_strike: + logger.info( + f"Risk: {(abs(strike-best_strike)*detail.multiplier)}, Buying Power: {buying_power}" ) - ): best_strike = strike best_mid = mid From 694835e4975f54900ca019a2ed308cbee6df17ed Mon Sep 17 00:00:00 2001 From: Sourcery AI <> Date: Thu, 3 Mar 2022 14:06:11 +0000 Subject: [PATCH 26/34] 'Refactored by Sourcery' --- looptrader/basetypes/Notifier/telegramnotifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/looptrader/basetypes/Notifier/telegramnotifier.py b/looptrader/basetypes/Notifier/telegramnotifier.py index 3870f0a..f2d2e84 100644 --- a/looptrader/basetypes/Notifier/telegramnotifier.py +++ b/looptrader/basetypes/Notifier/telegramnotifier.py @@ -405,7 +405,7 @@ def reply_text( parsemode: Union[DefaultValue[str], str, None], ): """Wrapper method to send reply texts""" - if message is None or "": + if message is None: return try: From 0c16a273bccccce0f3a4335eb2c97e9ccf17b79f Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Thu, 10 Mar 2022 13:18:36 +0000 Subject: [PATCH 27/34] cleanup --- looptrader/basetypes/Broker/tdaBroker.py | 2 +- looptrader/basetypes/Notifier/telegramnotifier.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/looptrader/basetypes/Broker/tdaBroker.py b/looptrader/basetypes/Broker/tdaBroker.py index 850c5f2..72d08cc 100644 --- a/looptrader/basetypes/Broker/tdaBroker.py +++ b/looptrader/basetypes/Broker/tdaBroker.py @@ -168,7 +168,7 @@ def get_option_chain( 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: diff --git a/looptrader/basetypes/Notifier/telegramnotifier.py b/looptrader/basetypes/Notifier/telegramnotifier.py index f2d2e84..7391947 100644 --- a/looptrader/basetypes/Notifier/telegramnotifier.py +++ b/looptrader/basetypes/Notifier/telegramnotifier.py @@ -405,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: From 6f0e92163ddf34884993561528c55ae151d33df2 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Fri, 11 Mar 2022 21:31:24 +0000 Subject: [PATCH 28/34] reorganized and cleaned up --- looptrader/__main__.py | 12 +- looptrader/basetypes/Strategy/helpers.py | 15 ++ .../Strategy/singlebydeltastrategy.py | 175 +++++++++--------- 3 files changed, 113 insertions(+), 89 deletions(-) diff --git a/looptrader/__main__.py b/looptrader/__main__.py index abaef0e..584def8 100644 --- a/looptrader/__main__.py +++ b/looptrader/__main__.py @@ -36,12 +36,22 @@ target_delta=0.03, min_delta=0.01, profit_target_percent=0.83, - portfolio_allocation_percent=2.0, + 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, + ) + # Create our brokers individualbroker = TdaBroker(id="individual") irabroker = TdaBroker(id="ira") 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 f777aad..6481e07 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -60,7 +60,7 @@ class SingleByDeltaStrategy(Strategy, Component): validator=attr.validators.instance_of(dt.timedelta), ) late_market_offset: dt.timedelta = attr.ib( - default=dt.timedelta(minutes=10), + default=dt.timedelta(minutes=0), validator=attr.validators.instance_of(dt.timedelta), ) after_hours_offset: dt.timedelta = attr.ib( @@ -296,64 +296,6 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: strike, quantity, offset_strike, offset_qty ) - 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 - - # 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, - 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 have an offset_strike and quantity > 0... - if ( - self.buy_or_sell == "SELL" - and not offsetting - and offset_strike is not None - and offset_qty > 0 - ): - # 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) - - # 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_offsetting_order( self, order: baseModels.Order ) -> Union[baseRR.PlaceOrderRequestMessage, None]: @@ -384,7 +326,10 @@ def build_offsetting_order( return None # Get Short Strike - short_strike = self.get_strike_from_symbol(order.legs[0].symbol) + 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( @@ -402,8 +347,6 @@ def build_closing_order( self, original_order: baseModels.Order ) -> Union[None, baseRR.PlaceOrderRequestMessage]: """Builds a closing order request message for a given position.""" - original_strike = 0.0 - # Build base order order_request = self.build_base_order_request_message(is_closing=True) @@ -415,7 +358,10 @@ def build_closing_order( break # Get the Strike - original_strike = self.get_strike_from_symbol(leg.symbol) + 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( @@ -429,7 +375,10 @@ def build_closing_order( return None pt = 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): + elif ( + isinstance(self.profit_target_percent, tuple) + and original_strike is not None + ): # Get current ticker price get_quote_request = baseRR.GetQuoteRequestMessage( self.strategy_id, [self.underlying] @@ -458,30 +407,67 @@ def build_closing_order( # Return request return order_request - 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 + 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]: - Args: - opening_instruction (str): Instruction of the opening order's leg + # If no valid qty, exit. + if qty is None or qty <= 0: + return None - Returns: - Union[str, None]: The closing instruction, or None if we shouldn't close this leg. - """ + # Determine how many legs are in the order + single_leg = offset_strike is None or offset_qty <= 0 - # 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 + # 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, + 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 have an offset_strike and quantity > 0... + if ( + self.buy_or_sell == "SELL" + and not offsetting + and offset_strike is not None + and offset_qty > 0 + ): + # 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) + + # 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 @@ -728,13 +714,26 @@ def get_current_offsets( return open_offsets.offset_legs - def get_strike_from_symbol(self, symbol: str): - match = re.search(r"([0-9])+$", symbol) + 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 - if match is not None: - original_strike = float(match.group()) + Args: + opening_instruction (str): Instruction of the opening order's leg - return original_strike + 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 #################### ### Option Chain ### @@ -873,7 +872,7 @@ def get_offsetting_strike( buying_power = self.calculate_actual_buying_power(account) # Determine max spread for the available buying power. - max_strike_width = buying_power / strikes[0].multiplier / quantity + max_strike_width = buying_power / 100 / quantity # Initialize values best_mid = float("inf") From 17dcd6528d12744b7a6595ad378ecda5c2a42414 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Mon, 14 Mar 2022 19:47:04 +0000 Subject: [PATCH 29/34] brokerage unit tests --- test/broker/test_tdaBroker.py | 45 +++++++++++++---------------------- 1 file changed, 17 insertions(+), 28 deletions(-) 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) From cdb7fcdc89cc1487badfd2a29e12d444a8331288 Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Mon, 14 Mar 2022 20:01:17 +0000 Subject: [PATCH 30/34] more cleanups --- .../Strategy/singlebydeltastrategy.py | 265 ++++++++++-------- 1 file changed, 145 insertions(+), 120 deletions(-) diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index 6481e07..289a4d4 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -56,11 +56,11 @@ class SingleByDeltaStrategy(Strategy, Component): validator=attr.validators.instance_of(dt.datetime), ) 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=0), + default=dt.timedelta(minutes=5), validator=attr.validators.instance_of(dt.timedelta), ) after_hours_offset: dt.timedelta = attr.ib( @@ -87,7 +87,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 @@ -237,7 +237,10 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: return None # Get our available BP - availbp = self.calculate_actual_buying_power(account) + availbp = self.calculate_strategy_buying_power( + account.currentbalances.buyingpower, + account.currentbalances.liquidationvalue, + ) # Get default option chain chainrequest = self.build_option_chain_request() @@ -256,7 +259,7 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: return None # Find best strike to trade - strike = self.get_best_strike( + strike, quantity = self.get_best_strike_and_quantity( expiration.strikes, availbp, account.currentbalances.liquidationvalue, @@ -268,29 +271,10 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: if strike is None: return None - # Calculate Quantity - quantity = self.calculate_order_quantity( - strike.strike, availbp, account.currentbalances.liquidationvalue + offset_strike, offset_qty = self.get_offset_strike_and_quantity( + account, expiration, strike, quantity ) - offset_strike = None - offset_qty = 0 - - # If we should immediately offset positions, decide how many we need. - if self.offset_sold_positions: - offset_qty = self.calculate_offset_leg_quantity( - quantity, expiration.expirationdate - ) - - if offset_qty > 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: - raise RuntimeError("No offset strike found when expected.") - # Return Order return self.build_opening_order_request( strike, quantity, offset_strike, offset_qty @@ -369,35 +353,13 @@ def build_closing_order( ) order_request.order.legs.append(new_leg) - # 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 - pt = 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) - and original_strike is not None - ): - # Get current ticker price - get_quote_request = baseRR.GetQuoteRequestMessage( - self.strategy_id, [self.underlying] - ) - current_quote = self.mediator.get_quote(get_quote_request) - - if current_quote is not None: - current_price = current_quote.instruments[0].lastPrice - - # Calculate opening position %OTM - percent_otm = abs((current_price - original_strike) / current_price) + if original_strike is None: + return None - # 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]) + pt = self.calculate_profit_target(original_strike) - logger.info(f"OTM: {percent_otm*100}%, PT: {pt*100}%") + if pt is None: + return None # Set and format the closing price order_request.order.price = helpers.format_order_price( @@ -425,6 +387,9 @@ def build_opening_order_request( # 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) @@ -432,7 +397,7 @@ def build_opening_order_request( first_leg = self.build_leg( strike.symbol, strike.description, - qty, + order_qty, "BUY" if offsetting else self.buy_or_sell, True, ) @@ -442,16 +407,11 @@ def build_opening_order_request( # Calculate price price = (strike.bid + strike.ask) / 2 - # If we have an offset_strike and quantity > 0... - if ( - self.buy_or_sell == "SELL" - and not offsetting - and offset_strike is not None - and offset_qty > 0 - ): + # 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, offset_qty, "BUY", True + offset_strike.symbol, offset_strike.description, order_qty, "BUY", True ) # Append the leg order_request.order.legs.append(long_leg) @@ -796,7 +756,7 @@ def get_next_expiration( # Return the min expiration return minexpiration - def get_best_strike( + def get_best_strike_and_quantity( self, strikes: dict[ float, baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike @@ -805,22 +765,23 @@ def get_best_strike( liquidation_value: float, days_to_expiration: int, underlying_last_price: float, - ) -> Union[baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, None]: + ) -> 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 = int(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: @@ -831,10 +792,10 @@ 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(self.min_delta) <= abs(calculated_delta) <= abs(self.target_delta): @@ -842,15 +803,45 @@ def get_best_strike( qty = self.calculate_order_quantity( strike, buying_power, liquidation_value ) - 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, @@ -869,7 +860,10 @@ def get_offsetting_strike( return None # Get Buying Power - buying_power = self.calculate_actual_buying_power(account) + buying_power = self.calculate_strategy_buying_power( + account.currentbalances.buyingpower, + account.currentbalances.liquidationvalue, + ) # Determine max spread for the available buying power. max_strike_width = buying_power / 100 / quantity @@ -924,11 +918,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): @@ -951,48 +959,67 @@ 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) + + if current_quote is None: + return None + + current_price = current_quote.instruments[0].lastPrice - for position in account.positions: - if ( - position.underlyingsymbol == self.underlying - and position.putcall == self.put_or_call - ): - usedbp += self.calculate_position_buying_power(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 account.currentbalances.liquidationvalue - usedbp + return pt - def calculate_position_buying_power( - self, position: baseRR.AccountPosition + def calculate_strategy_buying_power( + self, buying_power: float, liquidation_value: float ) -> float: - """Calculates the actual buying power for a given position + """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_order_quantity( - self, strike: float, buyingpower: float, liquidation_value: float + self, strike: float, buying_power: float, liquidation_value: float ) -> int: """Calculates the number of positions to open for a given account and strike.""" logger.debug("calculate_order_quantity") @@ -1001,15 +1028,12 @@ def calculate_order_quantity( max_loss = strike * 100 * float(self.max_loss_calc_percent) # Calculate max buying power to use - balance_to_risk = liquidation_value * float(self.portfolio_allocation_percent) - - remainingbalance = buyingpower - (liquidation_value - balance_to_risk) - - # Calculate trade size - trade_size = remainingbalance // max_loss + balance_to_risk = self.calculate_strategy_buying_power( + 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 @@ -1030,23 +1054,24 @@ def calculate_offset_leg_quantity( 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) - open_qty = 0 - - if open_orders is not None: - 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_qty += leg.quantity + # 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) - # If we do have offsets, sum up the quantity - offset_qty = sum(leg.quantity for leg in long_offsets) + 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 - (offset_qty - open_qty), 0) + return max(target_qty - open_offset_qty, 0) From 5b2bb2fd692b36b1b9c29402cd116d7220df0d20 Mon Sep 17 00:00:00 2001 From: Sourcery AI <> Date: Mon, 14 Mar 2022 20:02:34 +0000 Subject: [PATCH 31/34] 'Refactored by Sourcery' --- looptrader/basetypes/Broker/tdaBroker.py | 32 +++++++------------ .../Strategy/singlebydeltastrategy.py | 5 +-- .../Strategy/spreadsbydeltastrategy.py | 24 ++++---------- 3 files changed, 21 insertions(+), 40 deletions(-) diff --git a/looptrader/basetypes/Broker/tdaBroker.py b/looptrader/basetypes/Broker/tdaBroker.py index 72d08cc..5c202b1 100644 --- a/looptrader/basetypes/Broker/tdaBroker.py +++ b/looptrader/basetypes/Broker/tdaBroker.py @@ -100,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 @@ -128,9 +127,7 @@ 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 @@ -161,7 +158,7 @@ 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): @@ -172,9 +169,7 @@ def get_option_chain( 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 @@ -206,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 @@ -252,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 @@ -423,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 @@ -468,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() @@ -727,7 +719,7 @@ def translate_account_position_instrument( if strike_match is not None: accountposition.strikeprice = float(strike_match.group()) elif accountposition.assettype == "OPTION": - logger.error("No strike price found for {}".format(symbol)) + logger.error(f"No strike price found for {symbol}") # Map the other fields accountposition.description = instrument.get("description", str) diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index 289a4d4..14476c0 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -492,8 +492,9 @@ def build_leg( 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) @@ -773,7 +774,7 @@ def get_best_strike_and_quantity( # Set Variables best_strike = None best_premium = float(0) - best_quantity = int(0) + best_quantity = 0 # Calculate Risk Free Rate risk_free_rate = helpers.get_risk_free_rate() diff --git a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py index 2aaec46..a47b26b 100644 --- a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py +++ b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py @@ -61,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 @@ -75,10 +75,9 @@ 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 @@ -108,10 +107,7 @@ def process_pre_market(self): ) 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): @@ -485,18 +481,10 @@ 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 return int(trade_size) From 1a127a8903db379f50d4cf842a6e43449f9702ff Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Mon, 14 Mar 2022 22:59:09 +0000 Subject: [PATCH 32/34] black cleanup --- looptrader/basetypes/Strategy/spreadsbydeltastrategy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py index a47b26b..6af73e3 100644 --- a/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py +++ b/looptrader/basetypes/Strategy/spreadsbydeltastrategy.py @@ -484,7 +484,6 @@ def calculate_order_quantity( 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 return int(trade_size) From 77a8977685695fa2b4ff17572dc3898c889c7f1d Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Tue, 15 Mar 2022 15:37:20 +0000 Subject: [PATCH 33/34] added variable max_loss and indv stocks --- looptrader/__main__.py | 29 ++++++++++++++ looptrader/basetypes/Broker/tdaBroker.py | 3 +- .../Strategy/singlebydeltastrategy.py | 38 ++++++++++++++----- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/looptrader/__main__.py b/looptrader/__main__.py index 584def8..139f8ad 100644 --- a/looptrader/__main__.py +++ b/looptrader/__main__.py @@ -28,6 +28,33 @@ target_delta=0.07, min_delta=0.03, 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( @@ -66,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 5c202b1..15277fc 100644 --- a/looptrader/basetypes/Broker/tdaBroker.py +++ b/looptrader/basetypes/Broker/tdaBroker.py @@ -502,7 +502,8 @@ def translate_option_chain( for details in strikes.values(): detail: dict for detail in details: - if detail.get("settlementType", str) == "P": + settlement_type = detail.get("settlementType") + if settlement_type in ["P", " "]: strikeresponse = self.Build_Option_Chain_Strike(detail) expiry.strikes[ diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index 14476c0..4d235dd 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -44,9 +44,7 @@ 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: Union[float, tuple] = attr.ib(default=0.7) - max_loss_calc_percent: float = attr.ib( - default=0.2, validator=attr.validators.instance_of(float) - ) + max_loss_calc_percent: Union[float, dict[int, float]] = attr.ib(default=0.2) opening_order_loop_seconds: int = attr.ib( default=20, validator=attr.validators.instance_of(int) ) @@ -229,10 +227,10 @@ 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 @@ -579,7 +577,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 @@ -696,6 +694,22 @@ def get_closing_order_instruction( 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 ### #################### @@ -802,7 +816,7 @@ def get_best_strike_and_quantity( 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 + strike, buying_power, liquidation_value, days_to_expiration ) total_premium = option_mid_price * qty @@ -1020,13 +1034,17 @@ def calculate_strategy_buying_power( # return min(allocation_bp, buying_power) def calculate_order_quantity( - self, strike: float, buying_power: float, liquidation_value: float + self, strike: float, buying_power: 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 = self.calculate_strategy_buying_power( From e606a14de3db7685c32c9d4483a0293a26ba59bc Mon Sep 17 00:00:00 2001 From: Tyler Patterson Date: Fri, 27 May 2022 17:35:42 +0100 Subject: [PATCH 34/34] I forgot what this is --- .../Strategy/singlebydeltastrategy.py | 415 +++++++++++++++++- 1 file changed, 391 insertions(+), 24 deletions(-) diff --git a/looptrader/basetypes/Strategy/singlebydeltastrategy.py b/looptrader/basetypes/Strategy/singlebydeltastrategy.py index 4d235dd..b48e7b1 100644 --- a/looptrader/basetypes/Strategy/singlebydeltastrategy.py +++ b/looptrader/basetypes/Strategy/singlebydeltastrategy.py @@ -45,6 +45,9 @@ class SingleByDeltaStrategy(Strategy, Component): maximum_dte: int = attr.ib(default=4, validator=attr.validators.instance_of(int)) 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) ) @@ -235,10 +238,9 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: return None # Get our available BP - availbp = self.calculate_strategy_buying_power( - account.currentbalances.buyingpower, - account.currentbalances.liquidationvalue, - ) + # availbp = self.calculate_strategy_buying_power( + # account.currentbalances.liquidationvalue + # ) # Get default option chain chainrequest = self.build_option_chain_request() @@ -257,25 +259,49 @@ def build_new_order(self) -> Union[baseRR.PlaceOrderRequestMessage, None]: return None # Find best strike to trade - strike, quantity = self.get_best_strike_and_quantity( + ( + 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, + 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 - offset_strike, offset_qty = self.get_offset_strike_and_quantity( - account, expiration, strike, quantity - ) + # offset_strike, offset_qty = self.get_offset_strike_and_quantity( + # account, expiration, strike, quantity + # ) + + # # Return Order + # return self.build_opening_order_request( + # strike, quantity, offset_strike, offset_qty + # ) # Return Order - return self.build_opening_order_request( - strike, quantity, offset_strike, offset_qty + return self.build_opening_order_request_v2( + best_strike, + best_quantity, + best_premium, + best_offset_strike, + best_offset_qty, ) def build_offsetting_order( @@ -367,6 +393,59 @@ def build_closing_order( # Return request return order_request + def build_opening_order_request_v2( + self, + strike: baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, + order_qty: int, + premium: float, + offset_strike: Union[ + baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike, None + ] = None, + offset_qty: Union[int, None] = None, + offsetting: bool = False, + ) -> Union[baseRR.PlaceOrderRequestMessage, None]: + + # If no valid qty, exit. + if order_qty is None or order_qty <= 0: + return None + + # 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 ( + not single_leg + and offset_strike is not None + and offset_qty is not None + and offset_qty > 0 + ): + # 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) + + # 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, @@ -771,6 +850,204 @@ def get_next_expiration( # Return the min expiration return minexpiration + def get_best_strike_and_quantity_v2( + self, + strikes: dict[ + float, baseRR.GetOptionChainResponseMessage.ExpirationDate.Strike + ], + liquidation_value: float, + days_to_expiration: int, + underlying_last_price: 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[ @@ -815,8 +1092,8 @@ def get_best_strike_and_quantity( # Make sure strike delta is less then our target 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, days_to_expiration + qty = self.calculate_quantity_single_strike( + strike, liquidation_value, days_to_expiration ) total_premium = option_mid_price * qty @@ -876,8 +1153,7 @@ def get_offsetting_strike( # Get Buying Power buying_power = self.calculate_strategy_buying_power( - account.currentbalances.buyingpower, - account.currentbalances.liquidationvalue, + account.currentbalances.liquidationvalue ) # Determine max spread for the available buying power. @@ -1015,9 +1291,7 @@ def calculate_profit_target(self, strike_price: float) -> Union[None, float]: return pt - def calculate_strategy_buying_power( - self, buying_power: float, liquidation_value: float - ) -> float: + def calculate_strategy_buying_power(self, liquidation_value: float) -> float: """Calculates the actual buying power based on the MaxLossCalcPercentage and current account balances. Args: @@ -1033,8 +1307,22 @@ def calculate_strategy_buying_power( # Return the smaller value of our actual buying power and calculated maximum buying power # return min(allocation_bp, buying_power) - def calculate_order_quantity( - self, strike: float, buying_power: float, liquidation_value: float, dte: int = 2 + 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_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") @@ -1047,9 +1335,33 @@ def calculate_order_quantity( max_loss = strike * 100 * max_loss_percent # Calculate max buying power to use - balance_to_risk = self.calculate_strategy_buying_power( - buying_power, liquidation_value - ) + 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(balance_to_risk // max_loss) @@ -1094,3 +1406,58 @@ def calculate_offset_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