diff --git a/etc/ttcli.cfg b/etc/ttcli.cfg index d9279d3..d5221d4 100644 --- a/etc/ttcli.cfg +++ b/etc/ttcli.cfg @@ -13,8 +13,8 @@ portfolio-delta-variation = 5 [order] bp-warn-above-percent = 5 -[options] +[option] chain-show-delta = true -chain-show-iv = false -chain-show-oi = false +chain-show-volume = false +chain-show-open-interest = false chain-show-theta = false diff --git a/ttcli/app.py b/ttcli/app.py index f873c4a..fe7a6c7 100644 --- a/ttcli/app.py +++ b/ttcli/app.py @@ -4,6 +4,7 @@ import asyncclick as click from ttcli.option import option +from ttcli.portfolio import portfolio from ttcli.utils import CONTEXT_SETTINGS, VERSION, logger @@ -19,5 +20,6 @@ def main(): logger.debug('Using Windows-specific event loop policy') app.add_command(option) + app.add_command(portfolio) app(_anyio_backend='asyncio') diff --git a/ttcli/option.py b/ttcli/option.py index 0005850..ab5db74 100644 --- a/ttcli/option.py +++ b/ttcli/option.py @@ -65,7 +65,29 @@ async def listen_greeks( return greeks_dict -@click.group(chain=True, help='Buy, sell, and analyze options.') +async def listen_summaries( + n_summaries: int, + streamer: DXLinkStreamer +) -> dict[str, Quote]: + summary_dict = {} + async for summary in streamer.listen(EventType.SUMMARY): + summary_dict[summary.eventSymbol] = summary + if len(summary_dict) == n_summaries: + return summary_dict + + +async def listen_trades( + n_trades: int, + streamer: DXLinkStreamer +) -> dict[str, Quote]: + trade_dict = {} + async for trade in streamer.listen(EventType.TRADE): + trade_dict[trade.eventSymbol] = trade + if len(trade_dict) == n_trades: + return trade_dict + + +@click.group(help='Buy, sell, and analyze options.') async def option(): pass @@ -194,6 +216,9 @@ async def call(symbol: str, quantity: int, strike: Optional[Decimal] = None, wid if data.warnings: for warning in data.warnings: print_warning(warning.message) + warn_percent = sesh.config.getint('order', 'bp-warn-above-percent', fallback=None) + if warn_percent and percent > warn_percent: + print_warning(f'Buying power usage is above target of {warn_percent}%!') if get_confirmation('Send order? Y/n '): acc.place_order(sesh, order, dry_run=False) @@ -322,6 +347,9 @@ async def put(symbol: str, quantity: int, strike: Optional[int] = None, width: O if data.warnings: for warning in data.warnings: print_warning(warning.message) + warn_percent = sesh.config.getint('order', 'bp-warn-above-percent', fallback=None) + if warn_percent and percent > warn_percent: + print_warning(f'Buying power usage is above target of {warn_percent}%!') if get_confirmation('Send order? Y/n '): acc.place_order(sesh, order, dry_run=False) @@ -527,6 +555,9 @@ async def strangle(symbol: str, quantity: int, call: Optional[Decimal] = None, w if data.warnings: for warning in data.warnings: print_warning(warning.message) + warn_percent = sesh.config.getint('order', 'bp-warn-above-percent', fallback=None) + if warn_percent and percent > warn_percent: + print_warning(f'Buying power usage is above target of {warn_percent}%!') if get_confirmation('Send order? Y/n '): acc.place_order(sesh, order, dry_run=False) @@ -539,7 +570,6 @@ async def strangle(symbol: str, quantity: int, call: Optional[Decimal] = None, w @click.argument('symbol', type=str) async def chain(symbol: str, strikes: int = 8, weeklies: bool = False): sesh = RenewableSession() - strike_price = None async with DXLinkStreamer(sesh) as streamer: await streamer.subscribe(EventType.QUOTE, [symbol]) quote = await streamer.get_event(EventType.QUOTE) @@ -552,13 +582,32 @@ async def chain(symbol: str, strikes: int = 8, weeklies: bool = False): console = Console() table = Table(show_header=True, header_style='bold', title_style='bold', title=f'Options chain for {symbol} expiring {expiration}') - table.add_column(u'Call \u0394', width=8, justify='center') + + show_delta = sesh.config.getboolean('option', 'chain-show-delta', fallback=True) + show_theta = sesh.config.getboolean('option', 'chain-show-theta', fallback=False) + show_oi = sesh.config.getboolean('option', 'chain-show-open-interest', fallback=False) + show_volume = sesh.config.getboolean('option', 'chain-show-volume', fallback=False) + if show_volume: + table.add_column(u'Volume', width=8, justify='right') + if show_oi: + table.add_column(u'Open Int', width=8, justify='right') + if show_theta: + table.add_column(u'Call \u03B8', width=6, justify='center') + if show_delta: + table.add_column(u'Call \u0394', width=6, justify='center') table.add_column('Bid', style='green', width=8, justify='center') table.add_column('Ask', style='red', width=8, justify='center') table.add_column('Strike', width=8, justify='center') table.add_column('Bid', style='green', width=8, justify='center') table.add_column('Ask', style='red', width=8, justify='center') - table.add_column(u'Put \u0394', width=8, justify='center') + if show_delta: + table.add_column(u'Put \u0394', width=6, justify='center') + if show_theta: + table.add_column(u'Put \u03B8', width=6, justify='center') + if show_oi: + table.add_column(u'Open Int', width=8, justify='right') + if show_volume: + table.add_column(u'Volume', width=8, justify='right') if strikes * 2 < len(subchain.strikes): mid_index = 0 @@ -572,30 +621,49 @@ async def chain(symbol: str, strikes: int = 8, weeklies: bool = False): [s.put_streamer_symbol for s in all_strikes]) await streamer.subscribe(EventType.QUOTE, dxfeeds) await streamer.subscribe(EventType.GREEKS, dxfeeds) + if show_oi: + await streamer.subscribe(EventType.SUMMARY, dxfeeds) + if show_volume: + await streamer.subscribe(EventType.TRADE, dxfeeds) greeks_dict = await listen_greeks(len(dxfeeds), streamer) # take into account the symbol we subscribed to quote_dict = await listen_quotes(len(dxfeeds), streamer, skip=symbol) + if show_oi: + summary_dict = await listen_summaries(len(dxfeeds), streamer) + if show_volume: + trade_dict = await listen_trades(len(dxfeeds), streamer) for i, strike in enumerate(all_strikes): put_bid = quote_dict[strike.put_streamer_symbol].bidPrice put_ask = quote_dict[strike.put_streamer_symbol].askPrice - put_delta = int(greeks_dict[strike.put_streamer_symbol].delta * 100) call_bid = quote_dict[strike.call_streamer_symbol].bidPrice call_ask = quote_dict[strike.call_streamer_symbol].askPrice - call_delta = int(greeks_dict[strike.call_streamer_symbol].delta * 100) - - table.add_row( - f'{call_delta:g}', + row = [ f'{call_bid:.2f}', f'{call_ask:.2f}', f'{strike.strike_price:.2f}', f'{put_bid:.2f}', - f'{put_ask:.2f}', - f'{put_delta:g}' - ) - if i == strikes - 1: - table.add_row('=======', u'\u25B2 ITM \u25B2', '=======', '=======', - '=======', u'\u25BC ITM \u25BC', '=======', style='white') + f'{put_ask:.2f}' + ] + prepend = [] + if show_delta: + put_delta = int(greeks_dict[strike.put_streamer_symbol].delta * 100) + call_delta = int(greeks_dict[strike.call_streamer_symbol].delta * 100) + prepend.append(f'{call_delta:g}') + row.append(f'{put_delta:g}') + + if show_theta: + prepend.append(f'{abs(greeks_dict[strike.put_streamer_symbol].theta):.2f}') + row.append(f'{abs(greeks_dict[strike.call_streamer_symbol].theta):.2f}') + if show_oi: + prepend.append(f'{summary_dict[strike.put_streamer_symbol].openInterest}') + row.append(f'{summary_dict[strike.call_streamer_symbol].openInterest}') + if show_volume: + prepend.append(f'{trade_dict[strike.put_streamer_symbol].dayVolume}') + row.append(f'{trade_dict[strike.call_streamer_symbol].dayVolume}') + + prepend.reverse() + table.add_row(*(prepend + row), end_section=(i == strikes - 1)) console.print(table) diff --git a/ttcli/portfolio.py b/ttcli/portfolio.py new file mode 100644 index 0000000..67a6725 --- /dev/null +++ b/ttcli/portfolio.py @@ -0,0 +1,6 @@ +import asyncclick as click + + +@click.group(help='View positions and stats for your portfolio.') +async def portfolio(): + pass diff --git a/ttcli/utils.py b/ttcli/utils.py index f62b936..8ad6a73 100644 --- a/ttcli/utils.py +++ b/ttcli/utils.py @@ -65,15 +65,6 @@ def __init__(self): default_path = os.path.join(sys.prefix, DEFAULT_CONFIG_PATH) token_path = os.path.join(os.path.expanduser('~'), TOKEN_PATH) - # load config - self.config = ConfigParser() - if not os.path.exists(custom_path): - # copy default config to user home dir - os.makedirs(os.path.dirname(custom_path), exist_ok=True) - shutil.copyfile(default_path, custom_path) - self.config.read(default_path) - self.config.read(custom_path) - logged_in = False # try to load token if os.path.exists(token_path): @@ -83,6 +74,15 @@ def __init__(self): # make sure token hasn't expired logged_in = self.validate() + # load config + self.config = ConfigParser() + if not os.path.exists(custom_path): + # copy default config to user home dir + os.makedirs(os.path.dirname(custom_path), exist_ok=True) + shutil.copyfile(default_path, custom_path) + self.config.read(default_path) + self.config.read(custom_path) + if not logged_in: # either the token expired or doesn't exist username, password = self._get_credentials() @@ -99,12 +99,14 @@ def __init__(self): logger.debug('Logged in with cached session.') def _get_credentials(self): - username = (self.config['general'].get('username') or - os.getenv('TT_USERNAME')) + username = os.getenv('TT_USERNAME') + password = os.getenv('TT_PASSWORD') + if self.config.has_section('general'): + username = username or self.config['general'].get('username') + password = password or self.config['general'].get('password') + if not username: username = getpass.getpass('Username: ') - password = (self.config['general'].get('password') or - os.getenv('TT_PASSWORD')) if not password: password = getpass.getpass('Password: ')