diff --git a/thetagang.toml b/thetagang.toml index 25c7243bf..bb7378655 100644 --- a/thetagang.toml +++ b/thetagang.toml @@ -131,6 +131,15 @@ min_pnl = 0.0 # long positions are ignored. # close_at_pnl = 0.99 +# Optionally, if we try to roll the position and it fails, just close it. This +# can happen if the underlying moves too much and there are no suitable +# contracts. See https://github.com/brndnmtthws/thetagang/issues/347 and +# https://github.com/brndnmtthws/thetagang/issues/439 for a discussion on this. +# +# This can also be set per-symbol, with +# `symbols..close_if_unable_to_roll`. +close_if_unable_to_roll = false + [roll_when.calls] # Roll calls to the next expiration even if they're in the money. Defaults to # true if not specified. @@ -344,6 +353,9 @@ minimum_open_interest = 10 # value. # max_dte = 45 + # Optional: If we try to roll the position and it fails, just close it. + close_if_unable_to_roll = true + # parts = 30 [symbols.QQQ.puts] # Override delta just for QQQ puts @@ -386,35 +398,35 @@ minimum_open_interest = 10 green = false red = true -[symbols.QQQ.calls] -strike_limit = 100.0 # never write a call with a strike below $100 - -maintain_high_water_mark = true # maintain the high water mark when rolling calls - -# These values can (optionally) be set on a per-symbol basis, in addition to -# `write_when.calls.cap_factor` and `write_when.calls.cap_target_floor. -cap_factor = 1.0 -cap_target_floor = 0.0 - -[symbols.TLT] -weight = 0.2 -# parts = 20 -# Override delta for this particular symbol, for both puts and calls. -delta = 0.4 - -[symbols.ABNB] -# For symbols that require an exchange, which is typically any company stock, -# you must specify the primary exchange. -primary_exchange = "NASDAQ" -weight = 0.05 - -# parts = 5 -# Sometimes you may need to wrap the symbol in quotes. -[symbols."BRK B"] -# For symbols that require an exchange, which is typically any company stock, -# you must specify the primary exchange. -primary_exchange = "NYSE" -weight = 0.05 + [symbols.QQQ.calls] + strike_limit = 100.0 # never write a call with a strike below $100 + + maintain_high_water_mark = true # maintain the high water mark when rolling calls + + # These values can (optionally) be set on a per-symbol basis, in addition to + # `write_when.calls.cap_factor` and `write_when.calls.cap_target_floor. + cap_factor = 1.0 + cap_target_floor = 0.0 + + [symbols.TLT] + weight = 0.2 + # parts = 20 + # Override delta for this particular symbol, for both puts and calls. + delta = 0.4 + + [symbols.ABNB] + # For symbols that require an exchange, which is typically any company stock, + # you must specify the primary exchange. + primary_exchange = "NASDAQ" + weight = 0.05 + + # parts = 5 + # Sometimes you may need to wrap the symbol in quotes. + [symbols."BRK B"] + # For symbols that require an exchange, which is typically any company stock, + # you must specify the primary exchange. + primary_exchange = "NYSE" + weight = 0.05 # parts = 5 [ib_insync] diff --git a/thetagang/config.py b/thetagang/config.py index 28e00b56d..a46b4af56 100644 --- a/thetagang/config.py +++ b/thetagang/config.py @@ -111,6 +111,7 @@ def validate_config(config: Dict[str, Dict[str, Any]]) -> None: "dte": And(int, lambda n: 0 <= n), "min_pnl": float, Optional("close_at_pnl"): float, + Optional("close_if_unable_to_roll"): bool, Optional("max_dte"): And(int, lambda n: 1 <= n), Optional("calls"): { Optional("itm"): bool, @@ -154,6 +155,7 @@ def validate_config(config: Dict[str, Dict[str, Any]]) -> None: Optional("write_threshold_sigma"): And(float, lambda n: n > 0), Optional("max_dte"): And(int, lambda n: 1 <= n), Optional("dte"): And(int, lambda n: 0 <= n), + Optional("close_if_unable_to_roll"): bool, Optional("calls"): { Optional("delta"): And(float, lambda n: 0 <= n <= 1), Optional("write_threshold"): And(float, lambda n: 0 <= n <= 1), diff --git a/thetagang/config_defaults.py b/thetagang/config_defaults.py index f040b51c0..ac0f3fda7 100644 --- a/thetagang/config_defaults.py +++ b/thetagang/config_defaults.py @@ -30,6 +30,7 @@ "roll_when": { "min_pnl": 0.0, "close_at_pnl": 1.0, + "close_if_unable_to_roll": False, "calls": { "itm": True, "always_when_itm": False, diff --git a/thetagang/portfolio_manager.py b/thetagang/portfolio_manager.py index 2a1846bd3..7e03269dc 100644 --- a/thetagang/portfolio_manager.py +++ b/thetagang/portfolio_manager.py @@ -33,6 +33,7 @@ algo_params_from, calculate_net_short_positions, can_write_when, + close_if_unable_to_roll, count_long_option_positions, count_short_option_positions, get_higher_price, @@ -67,6 +68,12 @@ logging.getLogger("ib_insync.wrapper").setLevel(logging.CRITICAL) +class NoValidContractsError(Exception): + def __init__(self, message: str) -> None: + self.message = message + super().__init__(self.message) + + class PortfolioManager: def __init__( self, config: Dict[str, Dict[str, Any]], ib: IB, completion_future: Future[bool] @@ -697,9 +704,11 @@ def manage(self) -> None: ) console.print(Panel(Group(group1, group2))) - self.roll_puts(rollable_puts, account_summary) + closeable_puts += self.roll_puts(rollable_puts, account_summary) self.close_puts(closeable_puts) - self.roll_calls(rollable_calls, account_summary, portfolio_positions) + closeable_calls += self.roll_calls( + rollable_calls, account_summary, portfolio_positions + ) self.close_calls(closeable_calls) # check if we should do VIX call hedging @@ -917,14 +926,14 @@ def is_ok_to_write_calls( call_actions_table.add_row( symbol, "[cyan1]None", - f"[cyan1]Skipping because can_write_when_green={can_write_when_green} and marketPrice={ticker.marketPrice()} > close={ticker.close}", + f"[cyan1]Skipping because can_write_when_green={can_write_when_green} and marketPrice={ticker.marketPrice():.2f} > close={ticker.close}", ) return False if not can_write_when_red and ticker.marketPrice() < ticker.close: call_actions_table.add_row( symbol, "[cyan1]None", - f"[cyan1]Skipping because can_write_when_red={can_write_when_red} and marketPrice={ticker.marketPrice()} < close={ticker.close}", + f"[cyan1]Skipping because can_write_when_red={can_write_when_red} and marketPrice={ticker.marketPrice():.2f} < close={ticker.close}", ) return False @@ -1248,14 +1257,14 @@ def is_ok_to_write_puts( put_actions_table.add_row( symbol, "[cyan1]None", - f"[cyan1]Skipping because can_write_when_green={can_write_when_green} and marketPrice={ticker.marketPrice()} > close={ticker.close}", + f"[cyan1]Skipping because can_write_when_green={can_write_when_green} and marketPrice={ticker.marketPrice():.2f} > close={ticker.close}", ) return False if not can_write_when_red and ticker.marketPrice() < ticker.close: put_actions_table.add_row( symbol, "[cyan1]None", - f"[cyan1]Skipping because can_write_when_red={can_write_when_red} and marketPrice={ticker.marketPrice()} < close={ticker.close}", + f"[cyan1]Skipping because can_write_when_red={can_write_when_red} and marketPrice={ticker.marketPrice():.2f} < close={ticker.close}", ) return False @@ -1329,25 +1338,25 @@ def is_ok_to_write_puts( return (positions_summary_table, put_actions_table, to_write) - def close_puts(self, puts: List[Any]) -> None: + def close_puts(self, puts: List[PortfolioItem]) -> None: return self.close_positions(puts) def roll_puts( self, - puts: List[Any], + puts: List[PortfolioItem], account_summary: Dict[str, AccountValue], - ) -> None: + ) -> List[PortfolioItem]: return self.roll_positions(puts, "P", account_summary) - def close_calls(self, calls: List[Any]) -> None: + def close_calls(self, calls: List[PortfolioItem]) -> None: return self.close_positions(calls) def roll_calls( self, - calls: List[Any], + calls: List[PortfolioItem], account_summary: Dict[str, AccountValue], portfolio_positions: Dict[str, List[PortfolioItem]], - ) -> None: + ) -> List[PortfolioItem]: return self.roll_positions(calls, "C", account_summary, portfolio_positions) def close_positions(self, positions: List[Any]) -> None: @@ -1396,7 +1405,8 @@ def roll_positions( right: str, account_summary: Dict[str, AccountValue], portfolio_positions: Optional[Dict[str, List[PortfolioItem]]] = None, - ) -> None: + ) -> List[PortfolioItem]: + closeable_positions: List[PortfolioItem] = [] for position in positions: try: symbol = position.contract.symbol @@ -1549,11 +1559,23 @@ def fallback_minimum_price() -> float: # Enqueue order self.enqueue_order(combo, order) + except NoValidContractsError: + if close_if_unable_to_roll(self.config, position.contract.symbol): + console.print( + f"[yellow]Unable to find a suitable contract to roll to for {position.contract.localSymbol}. Closing position instead...", + ) + closeable_positions += [position] + else: + console.print_exception() + console.print( + "[yellow]Error occurred when trying to roll position. Continuing anyway...", + ) except RuntimeError: console.print_exception() console.print( "[yellow]Error occurred when trying to roll position. Continuing anyway...", ) + return closeable_positions def find_eligible_contracts( self, @@ -1624,8 +1646,8 @@ def valid_strike(strike: float) -> bool: and (not contract_max_dte or option_dte(exp) <= contract_max_dte) )[:chain_expirations] if len(expirations) < 1: - raise RuntimeError( - f"No valid contract expirations found for {main_contract.symbol}. Continuing anyway..." + raise NoValidContractsError( + f"No valid contract expirations found for {main_contract.symbol}. Continuing anyway...", ) rights = [right] @@ -1637,8 +1659,8 @@ def nearest_strikes(strikes: List[float]) -> List[float]: strikes = nearest_strikes(strikes) if len(strikes) < 1: - raise RuntimeError( - f"No valid contract strikes found for {main_contract.symbol}. Continuing anyway..." + raise NoValidContractsError( + f"No valid contract strikes found for {main_contract.symbol}. Continuing anyway...", ) console.print( f"Scanning between strikes {strikes[0]} and {strikes[-1]}," @@ -1801,8 +1823,8 @@ def filter_remaining_tickers( if len(tickers) < 1: # if there are _still_ no tickers remaining, there's nothing # more we can do - raise RuntimeError( - f"No valid contracts found for {main_contract.symbol}. Continuing anyway..." + raise NoValidContractsError( + f"No valid contracts found for {main_contract.symbol}. Continuing anyway...", ) elif fallback_minimum_price is not None: # if there's a fallback minimum price specified, try to find @@ -1897,11 +1919,11 @@ def vix_calls_should_be_closed() -> ( ) = vix_calls_should_be_closed() if close_vix_calls and vix_ticker and close_hedges_when_vix_exceeds: to_print.append( - f"[deep_sky_blue1]VIX={vix_ticker.marketPrice()}, which exceeds " + f"[deep_sky_blue1]VIX={vix_ticker.marketPrice():.2f}, which exceeds " f"vix_call_hedge.close_hedges_when_vix_exceeds={close_hedges_when_vix_exceeds}" ) status.update( - f"[bold blue_violet]VIX={vix_ticker.marketPrice()}, which exceeds " + f"[bold blue_violet]VIX={vix_ticker.marketPrice():.2f}, which exceeds " f"vix_call_hedge.close_hedges_when_vix_exceeds={close_hedges_when_vix_exceeds}, " "checking if we need to close positions...", ) @@ -1986,7 +2008,7 @@ def vix_calls_should_be_closed() -> ( break to_print.append( - f"VIXMO={vixmo_ticker.marketPrice()}, target call hedge weight={weight}", + f"VIXMO={vixmo_ticker.marketPrice():.2f}, target call hedge weight={weight}", ) allocation_amount = ( diff --git a/thetagang/thetagang.py b/thetagang/thetagang.py index 4bcb67067..db65c6c75 100755 --- a/thetagang/thetagang.py +++ b/thetagang/thetagang.py @@ -114,6 +114,12 @@ def start(config_path: str, without_ibc: bool = False) -> None: ">=", f"{pfmt(config['roll_when']['close_at_pnl'],0)}", ) + config_table.add_row( + "", + "Close if unable to roll", + "=", + f"{config['roll_when']['close_if_unable_to_roll']}", + ) config_table.add_section() config_table.add_row("[spring_green1]Roll options when either condition is true") diff --git a/thetagang/util.py b/thetagang/util.py index f23d3042a..6cd8e169d 100644 --- a/thetagang/util.py +++ b/thetagang/util.py @@ -396,3 +396,12 @@ def can_write_when( else config["write_when"][p_or_c]["red"] ) return (can_write_when_green, can_write_when_red) + + +def close_if_unable_to_roll(config: Dict[str, Any], symbol: str) -> bool: + close_if_unable_to_roll = ( + config["symbols"][symbol]["close_if_unable_to_roll"] + if "close_if_unable_to_roll" in config["symbols"][symbol] + else config["roll_when"]["close_if_unable_to_roll"] + ) + return close_if_unable_to_roll