import logging import nest_asyncio import pandas as pd import traceback from database.trades import upsert from dataclasses import replace from datetime import datetime from dotenv import load_dotenv from ibkr import Client, OptionLeg from ibkr.option_type import CALL, PUT from ibkr.order_action import BUY, SELL from options_chain import OptionsChain from option_type import OptionType from os import getenv load_dotenv() # Allows for starting an event loop even if there's already one running in the current thread. # Necessary for monitoring spread prices asynchronously while interacting with the IBKR client. nest_asyncio.apply() def monitor_spread_price(short_leg: OptionLeg, long_leg: OptionLeg, stop_price: float, client: Client): """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" Stop loss orders will not execute if trying to sell back a contract with no bid while paper trading. Therefore, the spread price must be monitored and the spread manually exited if the stop price is reached. If there is no bid for the long leg, only the short leg will be exited. """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" stopped_out = False market_data = {} # Stores real-time market data for each leg. def on_market_data_update(update_event): # Prevent the trade from being exited multiple times if there are other updates queued. # This will prevent unintentionally entering new trades. nonlocal stopped_out if stopped_out: return # Ensure there is market data for both legs before proceeding. if short_leg in market_data and long_leg in market_data: short_contract = market_data[short_leg] long_contract = market_data[long_leg] # If a contract has no bid -1.0 is returned, set it to 0 to avoid negative mid prices. mid_price_short = (max(short_contract.bid, 0) + short_contract.ask) / 2 mid_price_long = (max(long_contract.bid, 0) + long_contract.ask) / 2 current_spread_price = mid_price_short - mid_price_long logging.info(f'Short Contract: {short_leg.strike} {short_leg.option_type}') logging.info(f'Long Contract: {long_leg.strike} {long_leg.option_type}') logging.info(f'Current Spread Price: {current_spread_price}') logging.info(f'Stop Price: {stop_price}') if current_spread_price >= stop_price: stopped_out = True logging.info('Stop price reached or exceeded. Exiting trade.') short_leg_exit = replace(short_leg, action = BUY if short_leg.action == SELL else SELL) long_leg_exit = replace(long_leg, action = BUY if long_leg.action == SELL else SELL) if long_contract.bid > 0: exit_order = client.submit_spread_order(short_leg_exit, long_leg_exit) logging.info('Whole spread exited.') else: exit_order = client.submit_option_order(short_leg_exit) logging.info('Short leg only exited.') logging.info(f'Exit Slippage: {round(exit_order.fill_price - stop_price, 3)}') # Unsubscribe from market data updates once the trade has exited. for leg in [short_leg, long_leg]: market_data[leg].updateEvent -= on_market_data_update for leg in [short_leg, long_leg]: option_contract = client.get_option_contract(leg) leg_market_data = client.get_market_data(option_contract, streaming = True) market_data[leg] = leg_market_data leg_market_data.updateEvent += on_market_data_update def enter_iron_condor(entry_time: datetime): logging.basicConfig( filename = f'iron_condor_{entry_time.strftime("%H%M")}.log', level = logging.INFO, format = '%(asctime)s : %(levelname)s : %(message)s', datefmt = '%Y-%m-%d %H:%M:%S' ) try: _enter_iron_condor(entry_time) except Exception as e: logging.error('Error: %s', traceback.format_exc()) def _enter_iron_condor(entry_time: datetime): # The weekly symbol for SPX (SPXW) is required in order to distinguish from monthly options. symbol, sub_symbol = 'SPX', 'SPXW' expiration = datetime.now() options_chain = OptionsChain('$SPXW.X', expiration) logging.info(options_chain) credit_target = float(getenv('CREDIT_TARGET')) short_put_contract = options_chain.closest_contract_by_credit(credit_target, OptionType.PUT) short_call_contract = options_chain.closest_contract_by_credit(credit_target, OptionType.CALL) # When selecting long strikes, minimize the distance to a 50 point spread. # TODO: Select long strike based on preferred price. target_long_put_strike = short_put_contract['Strike'] - 50 target_long_call_strike = short_call_contract['Strike'] + 50 long_put_contract = options_chain.closest_contract_by_strike(target_long_put_strike, OptionType.PUT) long_call_contract = options_chain.closest_contract_by_strike(target_long_call_strike, OptionType.CALL) # Build the iron condor. short_put_strike = float(short_put_contract['Strike']) long_put_strike = float(long_put_contract['Strike']) short_call_strike = float(short_call_contract['Strike']) long_call_strike = float(long_call_contract['Strike']) logging.info(f'Short Put Strike: {short_put_strike}') logging.info(f'Long Put Strike: {long_put_strike}') logging.info(f'Short Call Strike: {short_call_strike}') logging.info(f'Long Call Strike: {long_call_strike}') ibkr_client = Client() short_call_leg = OptionLeg(symbol, expiration, short_call_strike, CALL, SELL, sub_symbol) long_call_leg = OptionLeg(symbol, expiration, long_call_strike, CALL, BUY, sub_symbol) call_spread_order = ibkr_client.submit_spread_order(short_call_leg, long_call_leg) call_spread_mid = call_spread_order.mid_price call_spread_limit = call_spread_order.limit_price call_spread_fill = call_spread_order.fill_price logging.info(f'Call Spread Mid Price: {call_spread_mid}') logging.info(f'Call Spread Limit Price: {call_spread_limit}') logging.info(f'Call Spread Fill Price: {call_spread_fill}') logging.info(f'Call Spread Slippage: {call_spread_fill - call_spread_mid}') monitor_spread_price( short_leg = short_call_leg, long_leg = long_call_leg, stop_price = call_spread_fill * 2, client = ibkr_client ) short_put_leg = OptionLeg(symbol, expiration, short_put_strike, PUT, SELL, sub_symbol) long_put_leg = OptionLeg(symbol, expiration, long_put_strike, PUT, BUY, sub_symbol) put_spread_order = ibkr_client.submit_spread_order(short_put_leg, long_put_leg) put_spread_mid = put_spread_order.mid_price put_spread_limit = put_spread_order.limit_price put_spread_fill = put_spread_order.fill_price logging.info(f'Put Spread Mid Price: {put_spread_mid}') logging.info(f'Put Spread Limit Price: {put_spread_limit}') logging.info(f'Put Spread Fill Price: {put_spread_fill}') logging.info(f'Put Spread Slippage: {put_spread_fill - put_spread_mid}') monitor_spread_price( short_leg = short_put_leg, long_leg = long_put_leg, stop_price = put_spread_fill * 2, client = ibkr_client ) call_spread_details = { 'Legs': [ {'Action': 'SELL', 'Strike': short_call_strike, 'Type': 'CALL'}, {'Action': 'BUY', 'Strike': long_call_strike, 'Type': 'CALL'} ], 'Open': call_spread_fill, 'Entry Slippage': round(call_spread_mid - call_spread_fill, 3) } put_spread_details = { 'Legs': [ {'Action': 'SELL', 'Strike': short_put_strike, 'Type': 'PUT'}, {'Action': 'BUY', 'Strike': long_put_strike, 'Type': 'PUT'} ], 'Open': put_spread_fill, 'Entry Slippage': round(put_spread_mid - put_spread_fill, 3) } upsert(pd.DataFrame([{ 'Date': datetime.now().date(), 'Symbol': symbol, 'Strategy': f'${credit_target:.2f} Iron Condor', 'Entry Time': entry_time.replace(tzinfo = None), 'Exit Time': None, 'Spreads': [call_spread_details, put_spread_details], 'Profit': None }])) # TODO: Add a shutdown hook. ibkr_client.run_event_loop()