diff --git a/README.md b/README.md index b9613e7..efef624 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,12 @@ $ pip install tastytrade-cli 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 +tt option view chains, buy or sell equities and futures options +tt pf (portfolio) view and close positions, check margin and analyze BP usage +tt trade buy or sell stocks/ETFs, crypto, and futures ``` Unavailable commands pending development: ``` -tt crypto buy, sell, and analyze cryptocurrencies -tt future buy, sell, and analyze futures tt order view, replace, and cancel orders tt wl (watchlist) view current prices and other data for symbols in your watchlists ``` diff --git a/pyproject.toml b/pyproject.toml index 81a141a..bf948b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ authors = [ dependencies = [ "asyncclick>=8.1.7.2", "rich>=13.8.1", - "tastytrade>=9.2", + "tastytrade>=9.3", ] [project.urls] @@ -29,6 +29,11 @@ dev-dependencies = [ "ruff>=0.6.7", ] +[tool.ruff.lint] +ignore = [ + "E731", # lambda-assignment +] + [project.scripts] tt = "ttcli.app:main" diff --git a/ttcli/app.py b/ttcli/app.py index e09ce60..bacb8b3 100644 --- a/ttcli/app.py +++ b/ttcli/app.py @@ -6,7 +6,7 @@ from ttcli.option import option from ttcli.portfolio import portfolio -from ttcli.stock import stock +from ttcli.trade import trade from ttcli.utils import CONTEXT_SETTINGS, VERSION, config_path @@ -19,7 +19,7 @@ async def app(): def main(): app.add_command(option) app.add_command(portfolio, name="pf") - app.add_command(stock) + app.add_command(trade) # create ttcli.cfg if it doesn't exist if not os.path.exists(config_path): diff --git a/ttcli/data/ttcli.cfg b/ttcli/data/ttcli.cfg index 0fbae5f..990d369 100644 --- a/ttcli/data/ttcli.cfg +++ b/ttcli/data/ttcli.cfg @@ -27,12 +27,16 @@ 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-mark-price = true show-trade-price = false show-delta = false show-theta = false show-gamma = false +[option] +# the default days to expiration to use for option-related commands; +# this bypasses the date selection menu. +# default-dte = 45 [option.chain] # these control whether the columns show up when running `tt option chain` show-delta = true diff --git a/ttcli/option.py b/ttcli/option.py index 58dc1a0..6e2ac28 100644 --- a/ttcli/option.py +++ b/ttcli/option.py @@ -1,6 +1,5 @@ import asyncio from decimal import Decimal -from typing import Optional import asyncclick as click from rich.console import Console @@ -17,12 +16,12 @@ Option, ) from tastytrade.order import NewOrder, OrderAction, OrderTimeInForce, OrderType -from tastytrade.utils import TastytradeError, get_tasty_monthly +from tastytrade.utils import TastytradeError, get_tasty_monthly, today_in_new_york from datetime import datetime from ttcli.utils import ( - ZERO, RenewableSession, + conditional_color, get_confirmation, is_monthly, listen_events, @@ -109,12 +108,12 @@ async def option(): async def call( symbol: str, quantity: int, - strike: Optional[Decimal] = None, - width: Optional[int] = None, + strike: Decimal | None = None, + width: int | None = None, gtc: bool = False, weeklies: bool = False, - delta: Optional[int] = None, - dte: Optional[int] = None, + delta: int | None = None, + dte: int | None = None, ): if strike is not None and delta is not None: print_error("Must specify either delta or strike, but not both.") @@ -150,14 +149,11 @@ async def call( else: subchain = choose_expiration(chain, weeklies) ticks = chain.tick_sizes - - def fmt(x: Decimal | None) -> Decimal: - return round_to_tick_size(x, ticks) if x is not None else ZERO + fmt = lambda x: round_to_tick_size(x, ticks) async with DXLinkStreamer(sesh) as streamer: if not strike: dxfeeds = [s.call_streamer_symbol for s in subchain.strikes] - await streamer.subscribe(Greeks, dxfeeds) greeks_dict = await listen_events(dxfeeds, Greeks, streamer) greeks = list(greeks_dict.values()) @@ -172,7 +168,7 @@ def fmt(x: Decimal | None) -> Decimal: strike = next( s.strike_price for s in subchain.strikes - if s.call_streamer_symbol == selected.eventSymbol + if s.call_streamer_symbol == selected.event_symbol # type: ignore ) strike_symbol = next( @@ -189,19 +185,19 @@ def fmt(x: Decimal | None) -> Decimal: dxfeeds = [strike_symbol, spread_strike.call_streamer_symbol] quote_dict = await listen_events(dxfeeds, Quote, streamer) bid = ( - quote_dict[strike_symbol].bidPrice # type: ignore - - quote_dict[spread_strike.call_streamer_symbol].askPrice # type: ignore + quote_dict[strike_symbol].bid_price + - quote_dict[spread_strike.call_streamer_symbol].ask_price ) ask = ( - quote_dict[strike_symbol].askPrice # type: ignore - - quote_dict[spread_strike.call_streamer_symbol].bidPrice # type: ignore + quote_dict[strike_symbol].ask_price + - quote_dict[spread_strike.call_streamer_symbol].bid_price ) else: await streamer.subscribe(Quote, [strike_symbol]) quote = await streamer.get_event(Quote) - bid = quote.bidPrice - ask = quote.askPrice - mid = fmt((bid + ask) / Decimal(2)) # type: ignore + bid = quote.bid_price + ask = quote.ask_price + mid = fmt((bid + ask) / Decimal(2)) console = Console() if width: table = Table( @@ -281,7 +277,7 @@ def fmt(x: Decimal | None) -> Decimal: nl = acc.get_balances(sesh).net_liquidating_value bp = data.buying_power_effect.change_in_buying_power - percent = bp / nl * Decimal(100) + percent = abs(bp) / nl * Decimal(100) fees = data.fee_calculation.total_fees # type: ignore table = Table( @@ -305,10 +301,10 @@ def fmt(x: Decimal | None) -> Decimal: f"${fmt(strike)}", "CALL", f"{subchain.expiration_date}", - f"${fmt(price)}", - f"${bp:.2f}", + conditional_color(fmt(price), round=False), + conditional_color(bp), f"{percent:.2f}%", - f"${fees:.2f}", + conditional_color(fees), ) if width: table.add_row( @@ -357,12 +353,12 @@ def fmt(x: Decimal | None) -> Decimal: async def put( symbol: str, quantity: int, - strike: Optional[Decimal] = None, - width: Optional[int] = None, + strike: Decimal | None = None, + width: int | None = None, gtc: bool = False, weeklies: bool = False, - delta: Optional[int] = None, - dte: Optional[int] = None, + delta: int | None = None, + dte: int | None = None, ): if strike is not None and delta is not None: print_error("Must specify either delta or strike, but not both.") @@ -398,14 +394,11 @@ async def put( else: subchain = choose_expiration(chain, weeklies) ticks = chain.tick_sizes - - def fmt(x: Decimal | None) -> Decimal: - return round_to_tick_size(x, ticks) if x is not None else ZERO + fmt = lambda x: round_to_tick_size(x, ticks) async with DXLinkStreamer(sesh) as streamer: if not strike: dxfeeds = [s.put_streamer_symbol for s in subchain.strikes] - await streamer.subscribe(Greeks, dxfeeds) greeks_dict = await listen_events(dxfeeds, Greeks, streamer) greeks = list(greeks_dict.values()) @@ -420,7 +413,7 @@ def fmt(x: Decimal | None) -> Decimal: strike = next( s.strike_price for s in subchain.strikes - if s.put_streamer_symbol == selected.eventSymbol + if s.put_streamer_symbol == selected.event_symbol # type: ignore ) strike_symbol = next( @@ -437,19 +430,19 @@ def fmt(x: Decimal | None) -> Decimal: dxfeeds = [strike_symbol, spread_strike.put_streamer_symbol] quote_dict = await listen_events(dxfeeds, Quote, streamer) bid = ( - quote_dict[strike_symbol].bidPrice # type: ignore - - quote_dict[spread_strike.put_streamer_symbol].askPrice # type: ignore + quote_dict[strike_symbol].bid_price + - quote_dict[spread_strike.put_streamer_symbol].ask_price ) ask = ( - quote_dict[strike_symbol].askPrice # type: ignore - - quote_dict[spread_strike.put_streamer_symbol].bidPrice # type: ignore + quote_dict[strike_symbol].ask_price + - quote_dict[spread_strike.put_streamer_symbol].bid_price ) else: await streamer.subscribe(Quote, [strike_symbol]) quote = await streamer.get_event(Quote) - bid = quote.bidPrice - ask = quote.askPrice - mid = fmt((bid + ask) / Decimal(2)) # type: ignore + bid = quote.bid_price + ask = quote.ask_price + mid = fmt((bid + ask) / Decimal(2)) console = Console() if width: table = Table( @@ -528,7 +521,7 @@ def fmt(x: Decimal | None) -> Decimal: nl = acc.get_balances(sesh).net_liquidating_value bp = data.buying_power_effect.change_in_buying_power - percent = bp / nl * Decimal(100) + percent = abs(bp) / nl * Decimal(100) fees = data.fee_calculation.total_fees # type: ignore table = Table( @@ -552,10 +545,10 @@ def fmt(x: Decimal | None) -> Decimal: f"${fmt(strike)}", "PUT", f"{subchain.expiration_date}", - f"${fmt(price)}", - f"${bp:.2f}", + conditional_color(fmt(price), round=False), + conditional_color(bp), f"{percent:.2f}%", - f"${fees:.2f}", + conditional_color(fees), ) if width: table.add_row( @@ -597,6 +590,7 @@ def fmt(x: Decimal | None) -> Decimal: type=int, help="Turns the order into an iron condor with the given width.", ) +@click.option("--dte", type=int, help="Days to expiration for the option.") @click.option("--gtc", is_flag=True, help="Place a GTC order instead of a day order.") @click.option( "--weeklies", is_flag=True, help="Show all expirations, not just monthlies." @@ -606,12 +600,13 @@ def fmt(x: Decimal | None) -> Decimal: async def strangle( symbol: str, quantity: int, - call: Optional[Decimal] = None, - width: Optional[int] = None, + call: Decimal | None = None, + width: int | None = None, + dte: int | None = None, gtc: bool = False, weeklies: bool = False, - delta: Optional[int] = None, - put: Optional[Decimal] = None, + delta: int | None = None, + put: Decimal | None = None, ): if (call is not None or put is not None) and delta is not None: print_error("Must specify either delta or strike, but not both.") @@ -627,15 +622,27 @@ async def strangle( symbol = symbol.upper() if symbol[0] == "/": # futures options chain = NestedFutureOptionChain.get_chain(sesh, symbol) - subchain = choose_futures_expiration(chain, weeklies) + if dte is not None: + subchain = min( + chain.option_chains[0].expirations, + key=lambda exp: abs(exp.days_to_expiration - dte), + ) + else: + subchain = choose_futures_expiration(chain, weeklies) ticks = subchain.tick_sizes else: chain = NestedOptionChain.get_chain(sesh, symbol) - subchain = choose_expiration(chain, weeklies) + if dte is not None: + subchain = min( + chain.expirations, + key=lambda exp: abs( + (exp.expiration_date - today_in_new_york()).days - dte + ), + ) + else: + subchain = choose_expiration(chain, weeklies) ticks = chain.tick_sizes - - def fmt(x: Decimal | None) -> Decimal: - return round_to_tick_size(x, ticks) if x is not None else ZERO + fmt = lambda x: round_to_tick_size(x, ticks) async with DXLinkStreamer(sesh) as streamer: if delta is not None: @@ -643,22 +650,24 @@ def fmt(x: Decimal | None) -> Decimal: call_dxf = [s.call_streamer_symbol for s in subchain.strikes] dxfeeds = put_dxf + call_dxf greeks_dict = await listen_events(dxfeeds, Greeks, streamer) - put_greeks = [v for v in greeks_dict.values() if v.eventSymbol in put_dxf] - call_greeks = [v for v in greeks_dict.values() if v.eventSymbol in call_dxf] + put_greeks = [v for v in greeks_dict.values() if v.event_symbol in put_dxf] + call_greeks = [ + v for v in greeks_dict.values() if v.event_symbol in call_dxf + ] lowest = 100 selected_put = None for g in put_greeks: diff = abs(g.delta * 100 + delta) if diff < lowest: - selected_put = g.eventSymbol + selected_put = g.event_symbol lowest = diff lowest = 100 selected_call = None for g in call_greeks: diff = abs(g.delta * 100 - delta) if diff < lowest: - selected_call = g.eventSymbol + selected_call = g.event_symbol lowest = diff # set strike with the closest delta put_strike = next( @@ -703,22 +712,22 @@ def fmt(x: Decimal | None) -> Decimal: ] quote_dict = await listen_events(dxfeeds, Quote, streamer) bid = ( - quote_dict[call_strike.call_streamer_symbol].bidPrice # type: ignore - + quote_dict[put_strike.put_streamer_symbol].bidPrice # type: ignore - - quote_dict[put_spread_strike.put_streamer_symbol].askPrice # type: ignore - - quote_dict[call_spread_strike.call_streamer_symbol].askPrice # type: ignore + quote_dict[call_strike.call_streamer_symbol].bid_price + + quote_dict[put_strike.put_streamer_symbol].bid_price + - quote_dict[put_spread_strike.put_streamer_symbol].ask_price + - quote_dict[call_spread_strike.call_streamer_symbol].ask_price ) ask = ( - quote_dict[call_strike.call_streamer_symbol].askPrice # type: ignore - + quote_dict[put_strike.put_streamer_symbol].askPrice # type: ignore - - quote_dict[put_spread_strike.put_streamer_symbol].bidPrice # type: ignore - - quote_dict[call_spread_strike.call_streamer_symbol].bidPrice # type: ignore + quote_dict[call_strike.call_streamer_symbol].ask_price + + quote_dict[put_strike.put_streamer_symbol].ask_price + - quote_dict[put_spread_strike.put_streamer_symbol].bid_price + - quote_dict[call_spread_strike.call_streamer_symbol].bid_price ) else: dxfeeds = [put_strike.put_streamer_symbol, call_strike.call_streamer_symbol] 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 + bid = sum([q.bid_price for q in quote_dict.values()]) + ask = sum([q.ask_price for q in quote_dict.values()]) mid = fmt((bid + ask) / Decimal(2)) console = Console() @@ -813,7 +822,7 @@ def fmt(x: Decimal | None) -> Decimal: nl = acc.get_balances(sesh).net_liquidating_value bp = data.buying_power_effect.change_in_buying_power - percent = bp / nl * Decimal(100) + percent = abs(bp) / nl * Decimal(100) fees = data.fee_calculation.total_fees # type: ignore table = Table(header_style="bold", title_style="bold", title="Order Review") @@ -832,10 +841,10 @@ def fmt(x: Decimal | None) -> Decimal: f"${fmt(put_strike.strike_price)}", "PUT", f"{subchain.expiration_date}", - f"${price:.2f}", - f"${bp:.2f}", + conditional_color(fmt(price), round=False), + conditional_color(bp), f"{percent:.2f}%", - f"${fees:.2f}", + conditional_color(fees), ) table.add_row( f"{quantity:+}", @@ -891,6 +900,7 @@ def fmt(x: Decimal | None) -> Decimal: @click.option( "-w", "--weeklies", is_flag=True, help="Show all expirations, not just monthlies." ) +@click.option("--dte", type=int, help="Days to expiration for the option.") @click.option( "-s", "--strikes", @@ -899,21 +909,36 @@ def fmt(x: Decimal | None) -> Decimal: help="The number of strikes to fetch above and below the spot price.", ) @click.argument("symbol", type=str) -async def chain(symbol: str, strikes: int = 8, weeklies: bool = False): +async def chain( + symbol: str, strikes: int = 8, weeklies: bool = False, dte: int | None = None +): 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) + if dte is not None: + subchain = min( + chain.option_chains[0].expirations, + key=lambda exp: abs(exp.days_to_expiration - dte), + ) + else: + subchain = choose_futures_expiration(chain, weeklies) ticks = subchain.tick_sizes else: chain = NestedOptionChain.get_chain(sesh, symbol) + if dte is not None: + subchain = min( + chain.expirations, + key=lambda exp: abs( + (exp.expiration_date - today_in_new_york()).days - dte + ), + ) + else: + subchain = choose_expiration(chain, weeklies) ticks = chain.tick_sizes - subchain = choose_expiration(chain, weeklies) - - def fmt(x: Decimal | None) -> Decimal: - return round_to_tick_size(x, ticks) if x is not None else ZERO + fmt = lambda x: round_to_tick_size(x, ticks) console = Console() table = Table( @@ -982,10 +1007,9 @@ 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 + trade_dict[trade.event_symbol] = 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)) @@ -1008,10 +1032,10 @@ async def listen_trades(trade: Trade, symbol: str) -> dict[str, Trade]: trade_dict = trade_task.result() # type: ignore 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 - call_bid = quote_dict[strike.call_streamer_symbol].bidPrice - call_ask = quote_dict[strike.call_streamer_symbol].askPrice + put_bid = quote_dict[strike.put_streamer_symbol].bid_price + put_ask = quote_dict[strike.put_streamer_symbol].ask_price + call_bid = quote_dict[strike.call_streamer_symbol].bid_price + call_ask = quote_dict[strike.call_streamer_symbol].ask_price row = [ f"{fmt(call_bid)}", f"{fmt(call_ask)}", @@ -1033,12 +1057,12 @@ async def listen_trades(trade: Trade, symbol: str) -> dict[str, Trade]: 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}" # type: ignore + f"{summary_dict[strike.put_streamer_symbol].open_interest}" # type: ignore ) - row.append(f"{summary_dict[strike.call_streamer_symbol].openInterest}") # type: ignore + row.append(f"{summary_dict[strike.call_streamer_symbol].open_interest}") # type: ignore if show_volume: - prepend.append(f"{trade_dict[strike.put_streamer_symbol].dayVolume}") # type: ignore - row.append(f"{trade_dict[strike.call_streamer_symbol].dayVolume}") # type: ignore + prepend.append(f"{trade_dict[strike.put_streamer_symbol].day_volume}") # type: ignore + row.append(f"{trade_dict[strike.call_streamer_symbol].day_volume}") # type: ignore prepend.reverse() table.add_row(*(prepend + row), end_section=(i == strikes - 1)) diff --git a/ttcli/portfolio.py b/ttcli/portfolio.py index 21009d9..73711a9 100644 --- a/ttcli/portfolio.py +++ b/ttcli/portfolio.py @@ -24,6 +24,7 @@ from ttcli.utils import ( ZERO, RenewableSession, + conditional_color, get_confirmation, print_error, print_warning, @@ -35,15 +36,6 @@ async def portfolio(): pass -def conditional_color(value: Decimal, dollars: bool = True) -> str: - d = "$" if dollars else "" - return ( - f"[red]-{d}{abs(value):.2f}[/red]" - if value < 0 - else f"[green]{d}{value:.2f}[/green]" - ) - - def get_indicators(today: date, metrics: MarketMetricInfo) -> str: indicators = [] if metrics.dividend_next_date and metrics.dividend_next_date > today: @@ -133,20 +125,20 @@ async def positions(all: bool = False): summary_dict: dict[str, Decimal] = {} async with DXLinkStreamer(sesh) as streamer: if greeks_symbols != []: - await streamer.subscribe(Greeks, greeks_symbols) # type: ignore + await streamer.subscribe(Greeks, greeks_symbols) + # TODO: handle empty await streamer.subscribe(Summary, all_symbols) # type: ignore await streamer.subscribe(Trade, ["SPY"]) if greeks_symbols != []: async for greeks in streamer.listen(Greeks): - greeks_dict[greeks.eventSymbol] = greeks + greeks_dict[greeks.event_symbol] = greeks if len(greeks_dict) == len(greeks_symbols): break spy = await streamer.get_event(Trade) async for summary in streamer.listen(Summary): - summary_dict[summary.eventSymbol] = summary.prevDayClosePrice or ZERO + summary_dict[summary.event_symbol] = summary.prev_day_close_price or ZERO if len(summary_dict) == len(all_symbols): break - spy_price = spy.price or 0 tt_symbols = set(pos.symbol for pos in positions) tt_symbols.update(set(o.underlying_symbol for o in options)) tt_symbols.update(set(o.underlying_symbol for o in future_options)) @@ -203,17 +195,17 @@ async def positions(all: bool = False): o = options_dict[pos.symbol] closing[i + 1] = o # BWD = beta * stock price * delta / index price - delta = greeks_dict[o.streamer_symbol].delta * 100 * m # type: ignore - theta = greeks_dict[o.streamer_symbol].theta * 100 * m # type: ignore - gamma = greeks_dict[o.streamer_symbol].gamma * 100 * m # type: ignore + delta = greeks_dict[o.streamer_symbol].delta * 100 * m + theta = greeks_dict[o.streamer_symbol].theta * 100 * m + gamma = greeks_dict[o.streamer_symbol].gamma * 100 * m metrics = metrics_dict[o.underlying_symbol] beta = metrics.beta or 0 - bwd = beta * mark * delta / spy_price + bwd = beta * mark * delta / spy.price ivr = (metrics.tos_implied_volatility_index_rank or 0) * 100 indicators = get_indicators(today, metrics) pnl = m * (mark_price - pos.average_open_price * pos.multiplier) trade_price = pos.average_open_price * pos.multiplier - day_change = mark_price - summary_dict[o.streamer_symbol] # type: ignore + day_change = mark_price - summary_dict[o.streamer_symbol] pnl_day = day_change * pos.quantity * pos.multiplier elif pos.instrument_type == InstrumentType.FUTURE_OPTION: o = future_options_dict[pos.symbol] @@ -226,19 +218,14 @@ async def positions(all: bool = False): metrics = metrics_dict[o.root_symbol] indicators = get_indicators(today, metrics) bwd = ( - ( - summary_dict[f.streamer_symbol] # type: ignore - * metrics.beta - * delta - / spy_price - ) + (summary_dict[f.streamer_symbol] * metrics.beta * delta / spy.price) if metrics.beta else 0 ) ivr = (metrics.tos_implied_volatility_index_rank or 0) * 100 trade_price = pos.average_open_price / f.display_factor pnl = (mark_price - trade_price) * m - day_change = mark_price - summary_dict[o.streamer_symbol] # type: ignore + day_change = mark_price - summary_dict[o.streamer_symbol] pnl_day = day_change * pos.quantity * pos.multiplier elif pos.instrument_type == InstrumentType.EQUITY: theta = 0 @@ -250,11 +237,11 @@ async def positions(all: bool = False): closing[i + 1] = e beta = metrics.beta or 0 indicators = get_indicators(today, metrics) - bwd = beta * mark_price * delta / spy_price + bwd = beta * mark_price * delta / spy.price ivr = (metrics.tos_implied_volatility_index_rank or 0) * 100 pnl = mark - pos.average_open_price * pos.quantity * m trade_price = pos.average_open_price - day_change = mark_price - summary_dict[pos.symbol] # type: ignore + day_change = mark_price - summary_dict[pos.symbol] pnl_day = day_change * pos.quantity elif pos.instrument_type == InstrumentType.FUTURE: theta = 0 @@ -265,11 +252,11 @@ async def positions(all: bool = False): # BWD = beta * stock price * delta / index price metrics = metrics_dict[f.future_product.root_symbol] # type: ignore indicators = get_indicators(today, metrics) - bwd = (metrics.beta * mark_price * delta / spy_price) if metrics.beta else 0 + bwd = (metrics.beta * mark_price * delta / spy.price) if metrics.beta else 0 ivr = (metrics.tw_implied_volatility_index_rank or 0) * 100 trade_price = pos.average_open_price * f.notional_multiplier pnl = (mark_price - trade_price) * pos.quantity * m - day_change = mark_price - summary_dict[f.streamer_symbol] # type: ignore + day_change = mark_price - summary_dict[f.streamer_symbol] pnl_day = day_change * pos.quantity * pos.multiplier net_liq = pnl_day elif pos.instrument_type == InstrumentType.CRYPTOCURRENCY: @@ -576,14 +563,14 @@ async def margin(): bp_variation = sesh.config.getint( "portfolio", "bp-target-percent-variation", fallback=10 ) - if trade.price - bp_percent > bp_variation: # type: ignore + if trade.price - bp_percent > bp_variation: 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 + f"BP usage is relatively low given VIX level of {round(trade.price)}!" + ) + elif bp_percent - trade.price > bp_variation: warnings.append( - f"BP usage is relatively high given VIX level of {round(trade.price)}!" # type: ignore - ) # type: ignore + f"BP usage is relatively high given VIX level of {round(trade.price)}!" + ) for warning in warnings: print_warning(warning) diff --git a/ttcli/stock.py b/ttcli/stock.py deleted file mode 100644 index 0bd4e86..0000000 --- a/ttcli/stock.py +++ /dev/null @@ -1,118 +0,0 @@ -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/trade.py b/ttcli/trade.py new file mode 100644 index 0000000..efa6046 --- /dev/null +++ b/ttcli/trade.py @@ -0,0 +1,315 @@ +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 Cryptocurrency, Equity, Future, FutureProduct +from tastytrade.order import NewOrder, OrderAction, OrderTimeInForce, OrderType +from tastytrade.utils import TastytradeError + +from ttcli.utils import ( + RenewableSession, + conditional_color, + get_confirmation, + print_error, + print_warning, + round_to_tick_size, + round_to_width, +) + + +@click.group(chain=True, help="Buy or sell stocks/ETFs, crypto, and futures.") +async def trade(): + pass + + +@trade.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 stock(symbol: str, quantity: int, gtc: bool = False): + sesh = RenewableSession() + symbol = symbol.upper() + equity = Equity.get_equity(sesh, symbol) + fmt = lambda x: round_to_tick_size(x, equity.tick_sizes or []) + + async with DXLinkStreamer(sesh) as streamer: + await streamer.subscribe(Quote, [symbol]) + quote = await streamer.get_event(Quote) + bid = quote.bid_price + ask = quote.ask_price + mid = fmt((bid + ask) / Decimal(2)) + + 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 = abs(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, + conditional_color(fmt(price), round=False), + conditional_color(bp), + f"{percent:.2f}%", + conditional_color(fees), + ) + 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) + + +@trade.command(help="Buy cryptocurrency.") +@click.argument("symbol", type=str) +@click.argument("quantity", type=Decimal) +async def crypto(symbol: str, quantity: Decimal): + sesh = RenewableSession() + symbol = symbol.upper() + if "USD" not in symbol: + symbol += "/USD" + elif "/" not in symbol: + symbol = symbol.split("USD")[0] + "/USD" + crypto = Cryptocurrency.get_cryptocurrency(sesh, symbol) + fmt = lambda x: round_to_width(x, crypto.tick_size) + + async with DXLinkStreamer(sesh) as streamer: + await streamer.subscribe(Quote, [crypto.streamer_symbol]) # type: ignore + quote = await streamer.get_event(Quote) + bid = quote.bid_price + ask = quote.ask_price + mid = fmt((bid + ask) / Decimal(2)) + + 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 unit (default mid): ") + price = mid if not price else Decimal(price) + + leg = crypto.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, + 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 = abs(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, + conditional_color(fmt(price), round=False), + conditional_color(bp), + f"{percent:.2f}%", + conditional_color(fees), + ) + 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) + + +@trade.command(help="Buy or sell futures.") +@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 future(symbol: str, quantity: int, gtc: bool = False): + sesh = RenewableSession() + symbol = symbol.upper() + if not any(c.isdigit() for c in symbol): + product = FutureProduct.get_future_product(sesh, symbol) + fmt = ",".join([f" {m.name} ({m.value})" for m in product.active_months]) + print_error( + f"Please enter the full futures symbol!\nCurrent active months:{fmt}" + ) + return + future = Future.get_future(sesh, symbol) + fmt = lambda x: round_to_width(x, future.tick_size) + + async with DXLinkStreamer(sesh) as streamer: + await streamer.subscribe(Quote, [future.streamer_symbol]) + quote = await streamer.get_event(Quote) + bid = quote.bid_price + ask = quote.ask_price + mid = fmt((bid + ask) / Decimal(2)) + + 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 = future.build_leg( + Decimal(abs(quantity)), + OrderAction.SELL if quantity < 0 else OrderAction.BUY, + ) + 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 = abs(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("Expiration", justify="center") + table.add_column("Multiplier", 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"{future.expiration_date}", + f"{future.notional_multiplier:.2f}", + conditional_color(fmt(price), round=False), + conditional_color(bp), + f"{percent:.2f}%", + conditional_color(fees), + ) + 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 decb551..f5f890c 100644 --- a/ttcli/utils.py +++ b/ttcli/utils.py @@ -10,7 +10,6 @@ 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.instruments import TickSize from tastytrade.streamer import U @@ -34,6 +33,17 @@ def print_warning(msg: str): rich_print(f"[light_coral]Warning: {msg}[/light_coral]") +def conditional_color(value: Decimal, dollars: bool = True, round: bool = True) -> str: + d = "$" if dollars else "" + if round: + return ( + f"[red]-{d}{abs(value):.2f}[/red]" + if value < 0 + else f"[green]{d}{value:.2f}[/green]" + ) + return f"[red]-{d}{abs(value)}[/red]" if value < 0 else f"[green]{d}{value}[/green]" + + def round_to_width(x, base=Decimal(1)): return base * round(x / base) @@ -51,9 +61,7 @@ async def listen_events( 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: # type: ignore - continue - event_dict[event.eventSymbol] = event + event_dict[event.event_symbol] = event if len(event_dict) == len(dxfeeds): return event_dict return event_dict # unreachable diff --git a/uv.lock b/uv.lock index 7067ba3..2ee244f 100644 --- a/uv.lock +++ b/uv.lock @@ -934,7 +934,7 @@ wheels = [ [[package]] name = "tastytrade" -version = "9.2" +version = "9.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -942,9 +942,9 @@ dependencies = [ { name = "pydantic" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/e2/dfe31d53b3bd1a43553ef406f3a427eb179846812ca1e939535853a98f4b/tastytrade-9.2.tar.gz", hash = "sha256:0a67aea7da0d39f98babf377b274756251e54f2dd7ec35889f26d7c13280689f", size = 117936 } +sdist = { url = "https://files.pythonhosted.org/packages/56/2f/007a3c34053faf446dbab8b4fdb8a6225e2cc7ebb308c112cf12f7683ec6/tastytrade-9.3.tar.gz", hash = "sha256:16497a1074cfb9573feb32cb06927dd70c9afc45968e468e4b1e82bc78dee2a9", size = 118037 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/15/e7544a1d7d7433f5c33af9d261ef7ea10c472cb5e2afb4ac189b5ddcc7a5/tastytrade-9.2-py3-none-any.whl", hash = "sha256:fedd92a34d0697dbc4bc37c6942954d766b79eca6ab1f3e67a1a262bb770cdcc", size = 47790 }, + { url = "https://files.pythonhosted.org/packages/71/e9/099a81451ccaef1acc8d42fbcae68f968e4ef46914094a7b83ec6a1e5e9d/tastytrade-9.3-py3-none-any.whl", hash = "sha256:4eac9e998d7950dc5335f3fc1f04cbc3ea46f9b35b63d42e215c2816ce6c0f9e", size = 47771 }, ] [[package]] @@ -968,7 +968,7 @@ dev = [ requires-dist = [ { name = "asyncclick", specifier = ">=8.1.7.2" }, { name = "rich", specifier = ">=13.8.1" }, - { name = "tastytrade", specifier = ">=9.2" }, + { name = "tastytrade", specifier = ">=9.3" }, ] [package.metadata.requires-dev]