Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optionally close positions when rolling fails #442

Merged
merged 1 commit into from
Jun 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 41 additions & 29 deletions thetagang.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.<symbol>.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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions thetagang/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions thetagang/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 44 additions & 22 deletions thetagang/portfolio_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]

Expand All @@ -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]},"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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...",
)
Expand Down Expand Up @@ -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 = (
Expand Down
6 changes: 6 additions & 0 deletions thetagang/thetagang.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions thetagang/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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