diff --git a/Makefile b/Makefile index fe89a6c..66b463c 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,9 @@ install: uv sync - uv pip install . + uv pip install -e . lint: + uv run ruff format ttcli/ uv run ruff check ttcli/ uv run pyright ttcli/ diff --git a/README.md b/README.md index 9b9c276..93b4f70 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,12 @@ Available commands: ``` tt option view chains, buy/sell equities and futures options tt pf (portfolio) view & close positions, check margin and analyze BP usage +tt stock buy, sell, and analyze stock ``` Unavailable commands pending development: ``` tt crypto buy, sell, and analyze cryptocurrencies tt future buy, sell, and analyze futures -tt stock buy, sell, and analyze stock tt order view, replace, and cancel orders tt wl (watchlist) view current prices and other data for symbols in your watchlists ``` @@ -32,7 +32,9 @@ For more options, run `tt --help` or `tt --help`. ## Configuration -TODO +Many aspects of the CLI's behavior can be customized using the `ttcli.cfg` file generated upon the first usage of the CLI. The file is located in your OS's home directory followed by the path `.config/ttcli/ttcli.cfg`. If you don't know where that is, you can just run `python -c "from ttcli.utils import config_path; print(config_path)"`. + +The default configuration file contains lots of options along with explanations of what they do. ## Shell completion
diff --git a/pyproject.toml b/pyproject.toml index 1c8f484..81a141a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tastytrade-cli" -version = "0.2" +version = "0.3" description = "An easy-to-use command line interface for Tastytrade!" readme = "README.md" requires-python = ">=3.10" diff --git a/ttcli/app.py b/ttcli/app.py index abd93cf..e09ce60 100644 --- a/ttcli/app.py +++ b/ttcli/app.py @@ -1,8 +1,13 @@ +import os +import shutil +from importlib.resources import as_file, files + import asyncclick as click from ttcli.option import option from ttcli.portfolio import portfolio -from ttcli.utils import CONTEXT_SETTINGS, VERSION +from ttcli.stock import stock +from ttcli.utils import CONTEXT_SETTINGS, VERSION, config_path @click.group(context_settings=CONTEXT_SETTINGS) @@ -14,5 +19,14 @@ async def app(): def main(): app.add_command(option) app.add_command(portfolio, name="pf") + app.add_command(stock) + + # create ttcli.cfg if it doesn't exist + if not os.path.exists(config_path): + data_file = files("ttcli.data").joinpath("ttcli.cfg") + with as_file(data_file) as path: + # copy default config to user home dir + os.makedirs(os.path.dirname(config_path), exist_ok=True) + shutil.copyfile(path, config_path) app(_anyio_backend="asyncio") diff --git a/ttcli/data/ttcli.cfg b/ttcli/data/ttcli.cfg index f2d1f68..0fbae5f 100644 --- a/ttcli/data/ttcli.cfg +++ b/ttcli/data/ttcli.cfg @@ -1,25 +1,41 @@ [general] +# the username & password can be passed to the CLI in 3 ways: here, +# through the $TT_USERNAME/$TT_PASSWORD environment variables, or, +# if neither is present, entered manually. # username = foo # password = bar -# default-account = example + +# the account number to use by default for trades/portfolio commands. +# this bypasses the account choice menu. +# default-account = 5WX01234 [portfolio] +# this number controls how much BP can be used in total, relative to +# the current $VIX level, before the CLI will warn you. +# for example, with a VIX of 25, BP usage of 40%, and variation of 10, +# you'd be warned for high BP usage since the acceptable range would be +# VIX - variation < BP% < VIX + variation. you may also be warned for +# low usage if you're perceived to be using your capital inefficiently; +# e.g. with a VIX of 25, BP usage of 10%, and variation of 10. bp-target-percent-variation = 10 +# this sets an upper bound for the amount of BP that can be allocated +# to a single positions before the CLI will warn you. bp-max-percent-per-position = 5.0 +# this allows you to set a target beta-weighted delta for your portfolio; +# the CLI will warn you unless target - variation < BWD < target + variation. delta-target = 0 delta-variation = 5 [portfolio.positions] +# these control whether the columns show up when running `tt pf positions` show-mark-price = false show-trade-price = false show-delta = false show-theta = false show-gamma = false -[order] -bp-warn-above-percent = 5 # TODO: make this a float - [option.chain] +# these control whether the columns show up when running `tt option chain` show-delta = true -show-volume = true +show-volume = false show-open-interest = true -show-theta = true +show-theta = false diff --git a/ttcli/option.py b/ttcli/option.py index 18df9e9..58dc1a0 100644 --- a/ttcli/option.py +++ b/ttcli/option.py @@ -21,19 +21,17 @@ from datetime import datetime from ttcli.utils import ( + ZERO, RenewableSession, get_confirmation, is_monthly, listen_events, print_error, print_warning, + round_to_tick_size, ) -def round_to_width(x, base=Decimal(1)): - return base * round(x / base) - - def choose_expiration( chain: NestedOptionChain, include_weeklies: bool = False ) -> NestedOptionChainExpiration: @@ -129,6 +127,7 @@ async def call( return sesh = RenewableSession() + symbol = symbol.upper() if symbol[0] == "/": # futures options chain = NestedFutureOptionChain.get_chain(sesh, symbol) if dte is not None: @@ -138,7 +137,7 @@ async def call( ) else: subchain = choose_futures_expiration(chain, weeklies) - tick_size = subchain.tick_sizes[0].value + ticks = subchain.tick_sizes else: chain = NestedOptionChain.get_chain(sesh, symbol) if dte is not None: @@ -150,10 +149,10 @@ async def call( ) else: subchain = choose_expiration(chain, weeklies) - tick_size = chain.tick_sizes[0].value - precision: int = tick_size.as_tuple().exponent # type: ignore - precision = abs(precision) if precision < 0 else 0 - precision_str = f".{precision}f" + ticks = chain.tick_sizes + + def fmt(x: Decimal | None) -> Decimal: + return round_to_tick_size(x, ticks) if x is not None else ZERO async with DXLinkStreamer(sesh) as streamer: if not strike: @@ -180,9 +179,13 @@ async def call( s.call_streamer_symbol for s in subchain.strikes if s.strike_price == strike ) if width: - spread_strike = next( - s for s in subchain.strikes if s.strike_price == strike + width - ) + try: + spread_strike = next( + s for s in subchain.strikes if s.strike_price == strike + width + ) + except StopIteration: + print_error(f"Unable to locate option at strike {strike + width}!") + return dxfeeds = [strike_symbol, spread_strike.call_streamer_symbol] quote_dict = await listen_events(dxfeeds, Quote, streamer) bid = ( @@ -198,9 +201,7 @@ async def call( quote = await streamer.get_event(Quote) bid = quote.bidPrice ask = quote.askPrice - mid = (bid + ask) / Decimal(2) # type: ignore - mid = round_to_width(mid, tick_size) - + mid = fmt((bid + ask) / Decimal(2)) # type: ignore console = Console() if width: table = Table( @@ -219,9 +220,7 @@ async def call( table.add_column("Bid", style="green", justify="center") table.add_column("Mid", justify="center") table.add_column("Ask", style="red", justify="center") - table.add_row( - f"{bid:{precision_str}}", f"{mid:{precision_str}}", f"{ask:{precision_str}}" - ) + table.add_row(f"{fmt(bid)}", f"{fmt(mid)}", f"{fmt(ask)}") console.print(table) price = input("Please enter a limit price per quantity (default mid): ") @@ -271,10 +270,14 @@ async def call( time_in_force=OrderTimeInForce.GTC if gtc else OrderTimeInForce.DAY, order_type=OrderType.LIMIT, legs=legs, - price=price * m, + price=fmt(price * m), ) acc = sesh.get_account() - data = acc.place_order(sesh, order, dry_run=True) + try: + data = acc.place_order(sesh, order, dry_run=True) + except TastytradeError as e: + print_error(str(e)) + return nl = acc.get_balances(sesh).net_liquidating_value bp = data.buying_power_effect.change_in_buying_power @@ -299,10 +302,10 @@ async def call( table.add_row( f"{quantity:+}", symbol, - f"${strike:{precision_str}}", + f"${fmt(strike)}", "CALL", f"{subchain.expiration_date}", - f"${price:{precision_str}}", + f"${fmt(price)}", f"${bp:.2f}", f"{percent:.2f}%", f"${fees:.2f}", @@ -311,7 +314,7 @@ async def call( table.add_row( f"{-quantity:+}", symbol, - f"${spread_strike.strike_price:{precision_str}}", # type: ignore + f"${fmt(spread_strike.strike_price)}", # type: ignore "CALL", f"{subchain.expiration_date}", "-", @@ -324,11 +327,13 @@ async def call( if data.warnings: for warning in data.warnings: print_warning(warning.message) - warn_percent = sesh.config.getint( - "order", "bp-warn-above-percent", fallback=None + warn_percent = sesh.config.getfloat( + "portfolio", "bp-max-percent-per-position", fallback=None ) if warn_percent and percent > warn_percent: - print_warning(f"Buying power usage is above target of {warn_percent}%!") + print_warning( + f"Buying power usage is above per-position target of {warn_percent}%!" + ) if get_confirmation("Send order? Y/n "): acc.place_order(sesh, order, dry_run=False) @@ -370,6 +375,7 @@ async def put( return sesh = RenewableSession() + symbol = symbol.upper() if symbol[0] == "/": # futures options chain = NestedFutureOptionChain.get_chain(sesh, symbol) if dte is not None: @@ -379,7 +385,7 @@ async def put( ) else: subchain = choose_futures_expiration(chain, weeklies) - tick_size = subchain.tick_sizes[0].value + ticks = subchain.tick_sizes else: chain = NestedOptionChain.get_chain(sesh, symbol) if dte is not None: @@ -391,10 +397,10 @@ async def put( ) else: subchain = choose_expiration(chain, weeklies) - tick_size = chain.tick_sizes[0].value - precision: int = tick_size.as_tuple().exponent # type: ignore - precision = abs(precision) if precision < 0 else 0 - precision_str = f".{precision}f" + ticks = chain.tick_sizes + + def fmt(x: Decimal | None) -> Decimal: + return round_to_tick_size(x, ticks) if x is not None else ZERO async with DXLinkStreamer(sesh) as streamer: if not strike: @@ -421,9 +427,13 @@ async def put( s.put_streamer_symbol for s in subchain.strikes if s.strike_price == strike ) if width: - spread_strike = next( - s for s in subchain.strikes if s.strike_price == strike - width - ) + try: + spread_strike = next( + s for s in subchain.strikes if s.strike_price == strike - width + ) + except StopIteration: + print_error(f"Unable to locate option at strike {strike - width}!") + return dxfeeds = [strike_symbol, spread_strike.put_streamer_symbol] quote_dict = await listen_events(dxfeeds, Quote, streamer) bid = ( @@ -439,9 +449,7 @@ async def put( quote = await streamer.get_event(Quote) bid = quote.bidPrice ask = quote.askPrice - mid = (bid + ask) / Decimal(2) # type: ignore - mid = round_to_width(mid, tick_size) - + mid = fmt((bid + ask) / Decimal(2)) # type: ignore console = Console() if width: table = Table( @@ -460,9 +468,7 @@ async def put( table.add_column("Bid", style="green", justify="center") table.add_column("Mid", justify="center") table.add_column("Ask", style="red", justify="center") - table.add_row( - f"{bid:{precision_str}}", f"{mid:{precision_str}}", f"{ask:{precision_str}}" - ) + table.add_row(f"{fmt(bid)}", f"{fmt(mid)}", f"{fmt(ask)}") console.print(table) price = input("Please enter a limit price per quantity (default mid): ") @@ -510,7 +516,7 @@ async def put( time_in_force=OrderTimeInForce.GTC if gtc else OrderTimeInForce.DAY, order_type=OrderType.LIMIT, legs=legs, - price=price * m, + price=fmt(price * m), ) acc = sesh.get_account() @@ -543,10 +549,10 @@ async def put( table.add_row( f"{quantity:+}", symbol, - f"${strike:{precision_str}}", + f"${fmt(strike)}", "PUT", f"{subchain.expiration_date}", - f"${price:{precision_str}}", + f"${fmt(price)}", f"${bp:.2f}", f"{percent:.2f}%", f"${fees:.2f}", @@ -555,7 +561,7 @@ async def put( table.add_row( f"{-quantity:+}", symbol, - f"${spread_strike.strike_price:{precision_str}}", # type: ignore + f"${fmt(spread_strike.strike_price)}", # type: ignore "PUT", f"{subchain.expiration_date}", "-", @@ -568,11 +574,13 @@ async def put( if data.warnings: for warning in data.warnings: print_warning(warning.message) - warn_percent = sesh.config.getint( - "order", "bp-warn-above-percent", fallback=None + warn_percent = sesh.config.getfloat( + "portfolio", "bp-max-percent-per-position", fallback=None ) if warn_percent and percent > warn_percent: - print_warning(f"Buying power usage is above target of {warn_percent}%!") + print_warning( + f"Buying power usage is above per-position target of {warn_percent}%!" + ) if get_confirmation("Send order? Y/n "): acc.place_order(sesh, order, dry_run=False) @@ -616,17 +624,18 @@ async def strangle( return sesh = RenewableSession() + symbol = symbol.upper() if symbol[0] == "/": # futures options chain = NestedFutureOptionChain.get_chain(sesh, symbol) subchain = choose_futures_expiration(chain, weeklies) - tick_size = subchain.tick_sizes[0].value + ticks = subchain.tick_sizes else: chain = NestedOptionChain.get_chain(sesh, symbol) subchain = choose_expiration(chain, weeklies) - tick_size = chain.tick_sizes[0].value - precision: int = tick_size.as_tuple().exponent # type: ignore - precision = abs(precision) if precision < 0 else 0 - precision_str = f".{precision}f" + ticks = chain.tick_sizes + + def fmt(x: Decimal | None) -> Decimal: + return round_to_tick_size(x, ticks) if x is not None else ZERO async with DXLinkStreamer(sesh) as streamer: if delta is not None: @@ -663,16 +672,29 @@ async def strangle( call_strike = next(s for s in subchain.strikes if s.strike_price == call) if width: - put_spread_strike = next( - s - for s in subchain.strikes - if s.strike_price == put_strike.strike_price - width - ) - call_spread_strike = next( - s - for s in subchain.strikes - if s.strike_price == call_strike.strike_price + width - ) + try: + put_spread_strike = next( + s + for s in subchain.strikes + if s.strike_price == put_strike.strike_price - width + ) + except StopIteration: + print_error( + f"Unable to locate option at strike {put_strike.strike_price + width}!" + ) + return + try: + call_spread_strike = next( + s + for s in subchain.strikes + if s.strike_price == call_strike.strike_price + width + ) + except StopIteration: + print_error( + f"Unable to locate option at strike {call_strike.strike_price + width}!" + ) + return + dxfeeds = [ call_strike.call_streamer_symbol, put_strike.put_streamer_symbol, @@ -697,8 +719,7 @@ async def strangle( quote_dict = await listen_events(dxfeeds, Quote, streamer) bid = sum([q.bidPrice for q in quote_dict.values()]) # type: ignore ask = sum([q.askPrice for q in quote_dict.values()]) # type: ignore - mid = (bid + ask) / Decimal(2) - mid = round_to_width(mid, tick_size) + mid = fmt((bid + ask) / Decimal(2)) console = Console() if width: @@ -718,9 +739,7 @@ async def strangle( table.add_column("Bid", style="green", justify="center") table.add_column("Mid", justify="center") table.add_column("Ask", style="red", justify="center") - table.add_row( - f"{bid:{precision_str}}", f"{mid:{precision_str}}", f"{ask:{precision_str}}" - ) + table.add_row(f"{fmt(bid)}", f"{fmt(mid)}", f"{fmt(ask)}") console.print(table) price = input("Please enter a limit price per quantity (default mid): ") @@ -810,7 +829,7 @@ async def strangle( table.add_row( f"{quantity:+}", symbol, - f"${put_strike.strike_price:{precision_str}}", + f"${fmt(put_strike.strike_price)}", "PUT", f"{subchain.expiration_date}", f"${price:.2f}", @@ -821,7 +840,7 @@ async def strangle( table.add_row( f"{quantity:+}", symbol, - f"${call_strike.strike_price:{precision_str}}", + f"${fmt(call_strike.strike_price)}", "CALL", f"{subchain.expiration_date}", "-", @@ -833,7 +852,7 @@ async def strangle( table.add_row( f"{-quantity:+}", symbol, - f"${put_spread_strike.strike_price:{precision_str}}", # type: ignore + f"${fmt(put_spread_strike.strike_price)}", # type: ignore "PUT", f"{subchain.expiration_date}", "-", @@ -844,7 +863,7 @@ async def strangle( table.add_row( f"{-quantity:+}", symbol, - f"${call_spread_strike.strike_price:{precision_str}}", # type: ignore + f"${fmt(call_spread_strike.strike_price)}", # type: ignore "CALL", f"{subchain.expiration_date}", "-", @@ -858,10 +877,12 @@ async def strangle( for warning in data.warnings: print_warning(warning.message) warn_percent = sesh.config.getint( - "order", "bp-warn-above-percent", fallback=None + "portfolio", "bp-max-percent-per-position", fallback=None ) if warn_percent and percent > warn_percent: - print_warning(f"Buying power usage is above target of {warn_percent}%!") + print_warning( + f"Buying power usage is above per-position target of {warn_percent}%!" + ) if get_confirmation("Send order? Y/n "): acc.place_order(sesh, order, dry_run=False) @@ -880,17 +901,19 @@ async def strangle( @click.argument("symbol", type=str) async def chain(symbol: str, strikes: int = 8, weeklies: bool = False): sesh = RenewableSession() + symbol = symbol.upper() async with DXLinkStreamer(sesh) as streamer: if symbol[0] == "/": # futures options chain = NestedFutureOptionChain.get_chain(sesh, symbol) subchain = choose_futures_expiration(chain, weeklies) - precision: int = subchain.tick_sizes[0].value.as_tuple().exponent # type: ignore + ticks = subchain.tick_sizes else: chain = NestedOptionChain.get_chain(sesh, symbol) - precision: int = chain.tick_sizes[0].value.as_tuple().exponent # type: ignore + ticks = chain.tick_sizes subchain = choose_expiration(chain, weeklies) - precision = abs(precision) if precision < 0 else 0 - precision_str = f".{precision}f" + + def fmt(x: Decimal | None) -> Decimal: + return round_to_tick_size(x, ticks) if x is not None else ZERO console = Console() table = Table( @@ -934,16 +957,15 @@ async def chain(symbol: str, strikes: int = 8, weeklies: bool = False): if symbol[0] == "/": # futures options future = Future.get_future(sesh, subchain.underlying_symbol) # type: ignore - await streamer.subscribe(Quote, [future.streamer_symbol]) + await streamer.subscribe(Trade, [future.streamer_symbol]) else: - await streamer.subscribe(Quote, [symbol]) - quote = await streamer.get_event(Quote) - strike_price = (quote.bidPrice + quote.askPrice) / 2 # type: ignore + await streamer.subscribe(Trade, [symbol]) + trade = await streamer.get_event(Trade) subchain.strikes.sort(key=lambda s: s.strike_price) if strikes * 2 < len(subchain.strikes): mid_index = 0 - while subchain.strikes[mid_index].strike_price < strike_price: + while subchain.strikes[mid_index].strike_price < trade.price: # type: ignore mid_index += 1 all_strikes = subchain.strikes[mid_index - strikes : mid_index + strikes] else: @@ -956,18 +978,18 @@ async def chain(symbol: str, strikes: int = 8, weeklies: bool = False): # take into account the symbol we subscribed to streamer_symbol = symbol if symbol[0] != "/" else future.streamer_symbol # type: ignore - async def listen_quotes(quote: Quote, symbol: str) -> dict[str, Quote]: - quote_dict = {symbol: quote} - await streamer.subscribe(Quote, dxfeeds) - async for quote in streamer.listen(Quote): - if quote.bidPrice is not None and quote.askPrice is not None: - quote_dict[quote.eventSymbol] = quote - if len(quote_dict) == len(dxfeeds) + 1: - return quote_dict - return quote_dict # unreachable + async def listen_trades(trade: Trade, symbol: str) -> dict[str, Trade]: + trade_dict = {symbol: trade} + await streamer.subscribe(Trade, dxfeeds) + async for trade in streamer.listen(Trade): + if trade.price is not None: + trade_dict[trade.eventSymbol] = trade + if len(trade_dict) == len(dxfeeds) + 1: + return trade_dict + return trade_dict # unreachable greeks_task = asyncio.create_task(listen_events(dxfeeds, Greeks, streamer)) - quote_task = asyncio.create_task(listen_quotes(quote, streamer_symbol)) + quote_task = asyncio.create_task(listen_events(dxfeeds, Quote, streamer)) tasks = [greeks_task, quote_task] if show_oi: summary_task = asyncio.create_task( @@ -975,7 +997,7 @@ async def listen_quotes(quote: Quote, symbol: str) -> dict[str, Quote]: ) tasks.append(summary_task) if show_volume: - trade_task = asyncio.create_task(listen_events(dxfeeds, Trade, streamer)) + trade_task = asyncio.create_task(listen_trades(trade, streamer_symbol)) tasks.append(trade_task) await asyncio.gather(*tasks) # wait for all tasks greeks_dict = greeks_task.result() @@ -991,11 +1013,11 @@ async def listen_quotes(quote: Quote, symbol: str) -> dict[str, Quote]: call_bid = quote_dict[strike.call_streamer_symbol].bidPrice call_ask = quote_dict[strike.call_streamer_symbol].askPrice row = [ - f"{call_bid:{precision_str}}", - f"{call_ask:{precision_str}}", - f"{strike.strike_price:{precision_str}}", - f"{put_bid:{precision_str}}", - f"{put_ask:{precision_str}}", + f"{fmt(call_bid)}", + f"{fmt(call_ask)}", + f"{fmt(strike.strike_price)}", + f"{fmt(put_bid)}", + f"{fmt(put_ask)}", ] prepend = [] if show_delta: diff --git a/ttcli/portfolio.py b/ttcli/portfolio.py index 45d39e2..21009d9 100644 --- a/ttcli/portfolio.py +++ b/ttcli/portfolio.py @@ -551,9 +551,9 @@ async def margin(): if not entry: continue entry = cast(MarginReportEntry, entry) - bp = entry.buying_power - bp_percent = float(bp / margin.margin_equity * 100) - if bp_percent > max_percent: + bp = -entry.buying_power + bp_percent = abs(float(bp / margin.margin_equity * 100)) + if abs(bp_percent) > max_percent: warnings.append( f"Per-position BP usage is too high for {entry.description}, max is {max_percent}%!" ) @@ -561,16 +561,31 @@ async def margin(): *[entry.description, conditional_color(bp), f"{bp_percent:.1f}%"], end_section=(i == last_entry), ) + bp_percent = abs(round(margin.margin_requirement / margin.margin_equity * 100, 1)) table.add_row( *[ "", conditional_color(margin.margin_requirement), - f"{margin.margin_requirement / margin.margin_equity * 100:.1f}%", + f"{bp_percent}%", ] ) - console.print(table) - for warning in warnings: - print_warning(warning) + async with DXLinkStreamer(sesh) as streamer: + await streamer.subscribe(Trade, ["VIX"]) + trade = await streamer.get_event(Trade) + console.print(table) + bp_variation = sesh.config.getint( + "portfolio", "bp-target-percent-variation", fallback=10 + ) + if trade.price - bp_percent > bp_variation: # type: ignore + warnings.append( + f"BP usage is relatively low given VIX level of {round(trade.price)}!" # type: ignore + ) # type: ignore + elif bp_percent - trade.price > bp_variation: # type: ignore + warnings.append( + f"BP usage is relatively high given VIX level of {round(trade.price)}!" # type: ignore + ) # type: ignore + for warning in warnings: + print_warning(warning) @portfolio.command(help="View current balances for an account.") @@ -596,19 +611,8 @@ async def balance(): conditional_color(balances.cash_balance), conditional_color(balances.net_liquidating_value), conditional_color(balances.derivative_buying_power), - conditional_color(balances.maintenance_requirement), + conditional_color(-balances.maintenance_requirement), f"{bp_percent:.1f}%", ] ) - async with DXLinkStreamer(sesh) as streamer: - await streamer.subscribe(Trade, ["VIX"]) - trade = await streamer.get_event(Trade) - console.print(table) - bp_variation = sesh.config.getint( - "portfolio", "bp-target-percent-variation", fallback=10 - ) - vix_diff = trade.price - bp_percent # type: ignore - if abs(vix_diff) > bp_variation: - print_warning( - f"BP usage is dangerously high given VIX level of {round(trade.price)}!" # type: ignore - ) # type: ignore + console.print(table) diff --git a/ttcli/stock.py b/ttcli/stock.py new file mode 100644 index 0000000..0bd4e86 --- /dev/null +++ b/ttcli/stock.py @@ -0,0 +1,118 @@ +from decimal import Decimal + +import asyncclick as click +from rich.console import Console +from rich.table import Table +from tastytrade import DXLinkStreamer +from tastytrade.dxfeed import Quote +from tastytrade.instruments import Equity +from tastytrade.order import NewOrder, OrderAction, OrderTimeInForce, OrderType +from tastytrade.utils import TastytradeError + +from ttcli.utils import ( + ZERO, + RenewableSession, + get_confirmation, + print_error, + print_warning, + round_to_tick_size, +) + + +@click.group(chain=True, help="Buy, sell, and analyze stocks/ETFs.") +async def stock(): + pass + + +@stock.command(help="Buy or sell stocks/ETFs.") +@click.option("--gtc", is_flag=True, help="Place a GTC order instead of a day order.") +@click.argument("symbol", type=str) +@click.argument("quantity", type=int) +async def trade(symbol: str, quantity: int, gtc: bool = False): + sesh = RenewableSession() + symbol = symbol.upper() + equity = Equity.get_equity(sesh, symbol) + + def fmt(x: Decimal | None) -> Decimal: + return round_to_tick_size(x, equity.tick_sizes or []) if x is not None else ZERO + + async with DXLinkStreamer(sesh) as streamer: + await streamer.subscribe(Quote, [symbol]) + quote = await streamer.get_event(Quote) + bid = quote.bidPrice + ask = quote.askPrice + mid = fmt((bid + ask) / Decimal(2)) # type: ignore + + console = Console() + table = Table( + show_header=True, + header_style="bold", + title_style="bold", + title=f"Quote for {symbol}", + ) + table.add_column("Bid", style="green", justify="center") + table.add_column("Mid", justify="center") + table.add_column("Ask", style="red", justify="center") + table.add_row(f"{fmt(bid)}", f"{fmt(mid)}", f"{fmt(ask)}") + console.print(table) + + price = input("Please enter a limit price per share (default mid): ") + price = mid if not price else Decimal(price) + + leg = equity.build_leg( + Decimal(abs(quantity)), + OrderAction.SELL_TO_OPEN if quantity < 0 else OrderAction.BUY_TO_OPEN, + ) + m = 1 if quantity < 0 else -1 + order = NewOrder( + time_in_force=OrderTimeInForce.GTC if gtc else OrderTimeInForce.DAY, + order_type=OrderType.LIMIT, + legs=[leg], + price=price * m, + ) + acc = sesh.get_account() + try: + data = acc.place_order(sesh, order, dry_run=True) + except TastytradeError as e: + print_error(str(e)) + return + + nl = acc.get_balances(sesh).net_liquidating_value + bp = data.buying_power_effect.change_in_buying_power + percent = bp / nl * Decimal(100) + fees = data.fee_calculation.total_fees # type: ignore + + table = Table( + show_header=True, + header_style="bold", + title_style="bold", + title="Order Review", + ) + table.add_column("Quantity", justify="center") + table.add_column("Symbol", justify="center") + table.add_column("Price", justify="center") + table.add_column("BP", justify="center") + table.add_column("BP %", justify="center") + table.add_column("Fees", justify="center") + table.add_row( + f"{quantity:+}", + symbol, + f"${fmt(price)}", + f"${bp:.2f}", + f"{percent:.2f}%", + f"${fees:.2f}", + ) + console.print(table) + + if data.warnings: + for warning in data.warnings: + print_warning(warning.message) + warn_percent = sesh.config.getfloat( + "portfolio", "bp-max-percent-per-position", fallback=None + ) + if warn_percent and percent > warn_percent: + print_warning( + f"Buying power usage is above per-position target of {warn_percent}%!" + ) + if get_confirmation("Send order? Y/n "): + acc.place_order(sesh, order, dry_run=False) diff --git a/ttcli/utils.py b/ttcli/utils.py index bc44f18..decb551 100644 --- a/ttcli/utils.py +++ b/ttcli/utils.py @@ -2,18 +2,17 @@ import logging import os import pickle -import shutil from configparser import ConfigParser from datetime import date from decimal import Decimal -from importlib.resources import as_file, files from typing import Any, Type from httpx import AsyncClient, Client from rich import print as rich_print from tastytrade import Account, DXLinkStreamer, Session from tastytrade.dxfeed import Quote -from tastytrade.streamer import EventType, U +from tastytrade.instruments import TickSize +from tastytrade.streamer import U logger = logging.getLogger(__name__) VERSION = "0.3" @@ -24,6 +23,8 @@ CUSTOM_CONFIG_PATH = ".config/ttcli/ttcli.cfg" TOKEN_PATH = ".config/ttcli/.session" +config_path = os.path.join(os.path.expanduser("~"), CUSTOM_CONFIG_PATH) + def print_error(msg: str): rich_print(f"[bold red]Error: {msg}[/bold red]") @@ -33,13 +34,24 @@ def print_warning(msg: str): rich_print(f"[light_coral]Warning: {msg}[/light_coral]") +def round_to_width(x, base=Decimal(1)): + return base * round(x / base) + + +def round_to_tick_size(price: Decimal, ticks: list[TickSize]) -> Decimal: + for tick in ticks: + if tick.threshold is None or price < tick.threshold: + return round_to_width(price, tick.value) + return price # unreachable + + async def listen_events( dxfeeds: list[str], event_class: Type[U], streamer: DXLinkStreamer ) -> dict[str, U]: event_dict = {} await streamer.subscribe(event_class, dxfeeds) async for event in streamer.listen(event_class): - if event_class == Quote and (event.bidPrice is None or event.askPrice is None): # type: ignore + if event_class == Quote and event.bidPrice is None: # type: ignore continue event_dict[event.eventSymbol] = event if len(event_dict) == len(dxfeeds): @@ -49,10 +61,7 @@ async def listen_events( class RenewableSession(Session): def __init__(self): - custom_path = os.path.join(os.path.expanduser("~"), CUSTOM_CONFIG_PATH) - data_file = files("ttcli.data").joinpath("ttcli.cfg") token_path = os.path.join(os.path.expanduser("~"), TOKEN_PATH) - logged_in = False # try to load token if os.path.exists(token_path): @@ -63,15 +72,9 @@ def __init__(self): # make sure token hasn't expired logged_in = self.validate() - # load config + # load config; should always exist self.config = ConfigParser() - if not os.path.exists(custom_path): - with as_file(data_file) as path: - # copy default config to user home dir - os.makedirs(os.path.dirname(custom_path), exist_ok=True) - shutil.copyfile(path, custom_path) - self.config.read(path) - self.config.read(custom_path) + self.config.read(config_path) if not logged_in: # either the token expired or doesn't exist diff --git a/uv.lock b/uv.lock index a21c9af..7067ba3 100644 --- a/uv.lock +++ b/uv.lock @@ -949,7 +949,7 @@ wheels = [ [[package]] name = "tastytrade-cli" -version = "0.2" +version = "0.3" source = { editable = "." } dependencies = [ { name = "asyncclick" },