diff --git a/basic.csv b/basic.csv new file mode 100644 index 0000000..8295812 --- /dev/null +++ b/basic.csv @@ -0,0 +1,28 @@ +2013-01-15 00:00:00,93.879000,93.949000,92.874000,93.078000 +2013-01-16 00:00:00,93.079000,93.672000,92.458000,92.800000 +2013-01-17 00:00:00,92.799000,95.011000,92.629000,94.616000 +2013-01-18 00:00:00,94.617000,94.872000,94.157000,94.662000 +2013-01-20 17:00:00,94.649000,94.820000,93.965000,94.155000 +2013-01-21 00:00:00,94.159000,94.938000,93.726000,94.009000 +2013-01-22 00:00:00,94.011000,94.284000,93.147000,93.231000 +2013-01-23 00:00:00,93.229000,94.024000,92.793000,93.649000 +2013-01-24 00:00:00,93.650000,94.715000,93.559000,94.489000 +2013-01-25 00:00:00,94.490000,95.083000,94.472000,94.749000 +2013-01-27 17:00:00,94.819000,95.007000,94.652000,94.834000 +2013-01-28 00:00:00,94.835000,94.968000,94.082000,94.809000 +2013-01-29 00:00:00,94.803000,95.330000,94.370000,95.248000 +2013-01-30 00:00:00,95.245000,95.450000,94.255000,94.365000 +2013-01-31 00:00:00,94.372000,95.799000,94.328000,95.714000 +2013-02-01 00:00:00,95.715000,96.718000,95.457000,96.597000 +2013-02-03 17:00:00,96.716000,96.777000,96.370000,96.572000 +2013-02-04 00:00:00,96.574000,97.064000,95.968000,96.044000 +2013-02-05 00:00:00,96.043000,97.426000,95.945000,97.131000 +2013-02-06 00:00:00,97.133000,97.284000,96.092000,96.395000 +2013-02-07 00:00:00,96.396000,97.023000,95.813000,96.145000 +2013-02-08 00:00:00,96.146000,96.182000,95.124000,95.625000 +2013-02-10 17:00:00,95.623000,95.744000,95.210000,95.339000 +2013-02-11 00:00:00,95.336000,96.877000,95.168000,96.537000 +2013-02-12 00:00:00,96.536000,96.719000,95.776000,96.214000 +2013-02-13 00:00:00,96.216000,96.890000,96.114000,96.775000 +2013-02-14 00:00:00,96.771000,96.964000,95.609000,95.621000 +2013-02-15 00:00:00,95.622000,96.676000,95.521000,96.351000 diff --git a/candlesticks.dat b/candlesticks.dat new file mode 100644 index 0000000..1f249e9 --- /dev/null +++ b/candlesticks.dat @@ -0,0 +1,10 @@ +1 1.5 2 2.4 4 6. +2 1.5 3 3.5 4 5.5 +3 4.5 5 5.5 6 6.5 +4 3.7 4.5 5.0 5.5 6.1 +5 3.1 3.5 4.2 5 6.1 +6 1 4 5.0 6 9 +7 4 4 4.8 6 6.1 +8 4 5 5.1 6 6.1 +9 1.5 2 2.4 3 3.5 +10 2.7 3 3.5 4 4.3 diff --git a/pyproject.toml b/pyproject.toml index 9401a30..90b788d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tastytrade-cli" -version = "0.4" +version = "0.5" description = "An easy-to-use command line interface for Tastytrade!" readme = "README.md" requires-python = ">=3.10" @@ -16,7 +16,7 @@ authors = [ dependencies = [ "asyncclick>=8.1.7.2", "rich>=13.8.1", - "tastytrade>=9.3", + "tastytrade>=9.6", ] [project.urls] @@ -37,5 +37,10 @@ ignore = [ [project.scripts] tt = "ttcli.app:main" +[project.optional-dependencies] +plot = [ + "py-gnuplot>=1.2.1", +] + [tool.hatch.build.targets.wheel] packages = ["ttcli"] diff --git a/test.py b/test.py new file mode 100644 index 0000000..4f9b312 --- /dev/null +++ b/test.py @@ -0,0 +1,26 @@ +import os +from pygnuplot import gnuplot + +g = gnuplot.Gnuplot() + +# Set plotting style +g.set( + terminal="kittycairo transparent", + xdata="time", + timefmt='"%Y-%m-%d %H:%M:%S"', + xrange='["2013-01-15 00:00:00":"2013-02-15 23:59:59"]', + yrange="[*:*]", + datafile='separator ","', + palette="defined (-1 'red', 1 'green')", + cbrange="[-1:1]", + style="fill solid noborder", + boxwidth="60000 absolute", + title='"AUDJPY" textcolor rgbcolor "white"', +) +g.unset("colorbox") +os.system("clear") +g.plot( + "'basic.csv' using (strptime('%Y-%m-%d', strcol(1))):2:4:3:5:($5 < $2 ? -1 : 1) with candlesticks palette" +) +_ = input() +os.system("clear") diff --git a/ttcli/app.py b/ttcli/app.py index bacb8b3..b6cd283 100644 --- a/ttcli/app.py +++ b/ttcli/app.py @@ -5,6 +5,7 @@ import asyncclick as click from ttcli.option import option +from ttcli.order import order from ttcli.portfolio import portfolio from ttcli.trade import trade from ttcli.utils import CONTEXT_SETTINGS, VERSION, config_path @@ -18,6 +19,7 @@ async def app(): def main(): app.add_command(option) + app.add_command(order) app.add_command(portfolio, name="pf") app.add_command(trade) diff --git a/ttcli/data/ttcli.cfg b/ttcli/data/ttcli.cfg index 990d369..dfa6ae7 100644 --- a/ttcli/data/ttcli.cfg +++ b/ttcli/data/ttcli.cfg @@ -38,6 +38,8 @@ show-gamma = false # this bypasses the date selection menu. # default-dte = 45 [option.chain] +# the number of strikes to show +strike-count = 16 # these control whether the columns show up when running `tt option chain` show-delta = true show-volume = false diff --git a/ttcli/option.py b/ttcli/option.py index 6e2ac28..9b5f61a 100644 --- a/ttcli/option.py +++ b/ttcli/option.py @@ -1,4 +1,6 @@ import asyncio +import time +from collections import defaultdict from decimal import Decimal import asyncclick as click @@ -16,7 +18,7 @@ Option, ) from tastytrade.order import NewOrder, OrderAction, OrderTimeInForce, OrderType -from tastytrade.utils import TastytradeError, get_tasty_monthly, today_in_new_york +from tastytrade.utils import TastytradeError, get_tasty_monthly from datetime import datetime from ttcli.utils import ( @@ -32,17 +34,27 @@ def choose_expiration( - chain: NestedOptionChain, include_weeklies: bool = False + chain: NestedOptionChain, + dte: int | None, + weeklies: bool, ) -> NestedOptionChainExpiration: - exps = [e for e in chain.expirations] - if not include_weeklies: - exps = [e for e in exps if is_monthly(e.expiration_date)] + if weeklies: + exps = chain.expirations + else: + exps = [e for e in chain.expirations if is_monthly(e.expiration_date)] + if dte is not None: + return min( + exps, + key=lambda exp: abs( + (exp.expiration_date - datetime.now().date()).days - dte + ), + ) exps.sort(key=lambda e: e.expiration_date) - default = get_tasty_monthly() - default_option: NestedOptionChainExpiration + tasty_monthly = get_tasty_monthly() + default = exps[0] for i, exp in enumerate(exps): - if exp.expiration_date == default: - default_option = exp + if exp.expiration_date == tasty_monthly: + default = exp print(f"{i + 1}) {exp.expiration_date} (default)") else: print(f"{i + 1}) {exp.expiration_date}") @@ -52,19 +64,26 @@ def choose_expiration( raw = input("Please choose an expiration: ") choice = int(raw) except ValueError: - return default_option # type: ignore + return default return exps[choice - 1] def choose_futures_expiration( - chain: NestedFutureOptionChain, include_weeklies: bool = False + chain: NestedFutureOptionChain, + dte: int | None, + weeklies: bool, ) -> NestedFutureOptionChainExpiration: subchain = chain.option_chains[0] - if include_weeklies: - exps = [e for e in subchain.expirations] + if weeklies: + exps = subchain.expirations else: exps = [e for e in subchain.expirations if e.expiration_type != "Weekly"] + if dte is not None: + return min( + exps, + key=lambda exp: abs(exp.days_to_expiration - dte), + ) exps.sort(key=lambda e: e.expiration_date) # find closest to 45 DTE default = min(exps, key=lambda e: abs(e.days_to_expiration - 45)) @@ -126,28 +145,16 @@ async def call( return sesh = RenewableSession() + if dte is None: + dte = sesh.config.getint("option", "default-dte", fallback=None) symbol = symbol.upper() if symbol[0] == "/": # futures options chain = NestedFutureOptionChain.get_chain(sesh, symbol) - 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) + subchain = choose_futures_expiration(chain, dte, 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 - datetime.now().date()).days - dte - ), - ) - else: - subchain = choose_expiration(chain, weeklies) + subchain = choose_expiration(chain, dte, weeklies) ticks = chain.tick_sizes fmt = lambda x: round_to_tick_size(x, ticks) @@ -372,27 +379,15 @@ async def put( sesh = RenewableSession() symbol = symbol.upper() + if dte is None: + dte = sesh.config.getint("option", "default-dte", fallback=None) if symbol[0] == "/": # futures options chain = NestedFutureOptionChain.get_chain(sesh, symbol) - 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) + subchain = choose_futures_expiration(chain, dte, 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 - datetime.now().date()).days - dte - ), - ) - else: - subchain = choose_expiration(chain, weeklies) + subchain = choose_expiration(chain, dte, weeklies) ticks = chain.tick_sizes fmt = lambda x: round_to_tick_size(x, ticks) @@ -620,27 +615,15 @@ async def strangle( sesh = RenewableSession() symbol = symbol.upper() + if dte is None: + dte = sesh.config.getint("option", "default-dte", fallback=None) if symbol[0] == "/": # futures options chain = NestedFutureOptionChain.get_chain(sesh, symbol) - 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) + subchain = choose_futures_expiration(chain, dte, 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) + subchain = choose_expiration(chain, dte, weeklies) ticks = chain.tick_sizes fmt = lambda x: round_to_tick_size(x, ticks) @@ -901,42 +884,29 @@ async def strangle( "-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", - type=int, - default=8, - help="The number of strikes to fetch above and below the spot price.", -) +@click.option("-s", "--strikes", type=int, help="The number of strikes to fetch.") @click.argument("symbol", type=str) async def chain( - symbol: str, strikes: int = 8, weeklies: bool = False, dte: int | None = None + symbol: str, + strikes: int | None = None, + weeklies: bool = False, + dte: int | None = None, ): sesh = RenewableSession() symbol = symbol.upper() + if dte is None: + dte = sesh.config.getint("option", "default-dte", fallback=None) + if strikes is None: + strikes = sesh.config.getint("option", "strike-count", fallback=16) async with DXLinkStreamer(sesh) as streamer: if symbol[0] == "/": # futures options chain = NestedFutureOptionChain.get_chain(sesh, symbol) - 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) + subchain = choose_futures_expiration(chain, dte, 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) + subchain = choose_expiration(chain, dte, weeklies) ticks = chain.tick_sizes fmt = lambda x: round_to_tick_size(x, ticks) @@ -988,13 +958,17 @@ async def chain( trade = await streamer.get_event(Trade) subchain.strikes.sort(key=lambda s: s.strike_price) - if strikes * 2 < len(subchain.strikes): - mid_index = 0 + mid_index = 0 + if strikes < len(subchain.strikes): while subchain.strikes[mid_index].strike_price < trade.price: # type: ignore mid_index += 1 - all_strikes = subchain.strikes[mid_index - strikes : mid_index + strikes] + half = strikes // 2 + all_strikes = subchain.strikes[mid_index - half : mid_index + half] else: all_strikes = subchain.strikes + mid_index = 0 + while all_strikes[mid_index].strike_price < trade.price: # type: ignore + mid_index += 1 dxfeeds = [s.call_streamer_symbol for s in all_strikes] + [ s.put_streamer_symbol for s in all_strikes @@ -1002,15 +976,19 @@ async def chain( # take into account the symbol we subscribed to streamer_symbol = symbol if symbol[0] != "/" else future.streamer_symbol # type: ignore + trade_dict = defaultdict(lambda: 0) + trade_dict[streamer_symbol] = trade.day_volume or 0 - async def listen_trades(trade: Trade, symbol: str) -> dict[str, Trade]: - trade_dict = {symbol: trade} + async def listen_trades(dxfeeds, trade_dict, streamer): await streamer.subscribe(Trade, dxfeeds) + end_time = time.time() + 3 async for trade in streamer.listen(Trade): trade_dict[trade.event_symbol] = trade if len(trade_dict) == len(dxfeeds) + 1: - return trade_dict - return trade_dict # unreachable + return + if time.time() > end_time: + return + return greeks_task = asyncio.create_task(listen_events(dxfeeds, Greeks, streamer)) quote_task = asyncio.create_task(listen_events(dxfeeds, Quote, streamer)) @@ -1021,15 +999,15 @@ async def listen_trades(trade: Trade, symbol: str) -> dict[str, Trade]: ) tasks.append(summary_task) if show_volume: - trade_task = asyncio.create_task(listen_trades(trade, streamer_symbol)) + trade_task = asyncio.create_task( + listen_trades(dxfeeds, trade_dict, streamer) + ) tasks.append(trade_task) await asyncio.gather(*tasks) # wait for all tasks greeks_dict = greeks_task.result() quote_dict = quote_task.result() if show_oi: summary_dict = summary_task.result() # type: ignore - if show_volume: - trade_dict = trade_task.result() # type: ignore for i, strike in enumerate(all_strikes): put_bid = quote_dict[strike.put_streamer_symbol].bid_price @@ -1061,10 +1039,10 @@ async def listen_trades(trade: Trade, symbol: str) -> dict[str, Trade]: ) 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].day_volume}") # type: ignore - row.append(f"{trade_dict[strike.call_streamer_symbol].day_volume}") # type: ignore + prepend.append(f"{trade_dict[strike.put_streamer_symbol]}") # type: ignore + row.append(f"{trade_dict[strike.call_streamer_symbol]}") # type: ignore prepend.reverse() - table.add_row(*(prepend + row), end_section=(i == strikes - 1)) + table.add_row(*(prepend + row), end_section=(i == mid_index - 1)) console.print(table) diff --git a/ttcli/order.py b/ttcli/order.py new file mode 100644 index 0000000..658b886 --- /dev/null +++ b/ttcli/order.py @@ -0,0 +1,214 @@ +from datetime import datetime +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 ( + InstrumentType, + NewOrder, + OrderAction, + OrderTimeInForce, + OrderType, +) +from tastytrade.utils import TastytradeError + +from ttcli.utils import ( + ZERO, + RenewableSession, + conditional_color, + get_confirmation, + print_error, + print_warning, + round_to_tick_size, + round_to_width, +) + + +@click.group(chain=True, help="View, adjust, or cancel orders.") +async def order(): + pass + + +@order.command(help="List, adjust, or cancel orders.") +@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 live(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) + + +@order.command(help="Show order history.") +@click.option( + "--start-date", + type=click.DateTime(["%Y-%m-%d"]), + help="The start date for the search date range.", +) +@click.option( + "--end-date", + type=click.DateTime(["%Y-%m-%d"]), + help="The end date for the search date range.", +) +@click.option("-s", "--symbol", type=str, help="Filter by underlying symbol.") +@click.option( + "-t", + "--type", + type=click.Choice(list(InstrumentType)), + help="Filter by instrument type.", +) # type: ignore +@click.option( + "--asc", is_flag=True, help="Sort by ascending time instead of descending." +) +async def history( + start_date: datetime | None = None, + end_date: datetime | None = None, + symbol: str | None = None, + type: InstrumentType | None = None, + asc: bool = False, +): + sesh = RenewableSession() + acc = sesh.get_account() + history = acc.get_order_history( + sesh, + start_date=start_date.date() if start_date else None, + end_date=end_date.date() if end_date else None, + underlying_symbol=symbol if symbol and symbol[0] != "/" else None, + futures_symbol=symbol if symbol and symbol[0] == "/" else None, + underlying_instrument_type=type, + ) + if asc: + history.reverse() + console = Console() + table = Table( + show_header=True, + header_style="bold", + title_style="bold", + title=f"Order history for account {acc.nickname} ({acc.account_number})", + ) + table.add_column("Date/Time") + # table.add_column("Order ID") # option + table.add_column("Root Symbol") + table.add_column("Type") + # table.add_column("Time in Force") # option + table.add_column("Price", justify="right") + table.add_column("Status") + # leg info + table.add_column("Quantity") + table.add_column("Action") + table.add_column("Symbol") + for order in history: + table.add_row( + *[ + order.updated_at.strftime("%Y-%m-%d %H:%M"), + order.underlying_symbol, + order.order_type.value, + conditional_color(order.price or ZERO), + order.status.value, + str(order.legs[0].quantity), + order.legs[0].action.value, + order.legs[0].symbol, + ], + end_section=(len(order.legs) == 1), + ) + for i in range(1, len(order.legs)): + table.add_row( + *[ + "", + "", + "", + "", + "", + str(order.legs[i].quantity), + order.legs[i].action.value, + order.legs[i].symbol, + ], + end_section=(i == len(order.legs) - 1), + ) + console.print(table) diff --git a/ttcli/plot.py b/ttcli/plot.py new file mode 100644 index 0000000..225db54 --- /dev/null +++ b/ttcli/plot.py @@ -0,0 +1,125 @@ +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 ( + RenewableSession, + conditional_color, + get_confirmation, + print_error, + print_warning, + round_to_tick_size, +) + + +@click.group( + chain=True, help="Plot candle charts, portfolio P&L, or net liquidating value." +) +async def plot(): + pass + + +@plot.command(help="Plot candle (OHLC) charts for the given symbol.") +@click.option("--width", "-w", help="Timeframe for each candle.") +@click.argument("symbol", type=str) +async def candles(symbol: str, width: str): + pass + + +@plot.command(help="List, adjust, or cancel orders.") +@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 live(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) diff --git a/ttcli/portfolio.py b/ttcli/portfolio.py index 73711a9..7b9da3d 100644 --- a/ttcli/portfolio.py +++ b/ttcli/portfolio.py @@ -1,3 +1,4 @@ +import asyncio from collections import defaultdict from datetime import date, datetime from decimal import Decimal @@ -9,7 +10,14 @@ from tastytrade import DXLinkStreamer from tastytrade.account import MarginReportEntry from tastytrade.dxfeed import Greeks, Summary, Trade -from tastytrade.instruments import Cryptocurrency, Equity, Future, FutureOption, Option +from tastytrade.instruments import ( + Cryptocurrency, + Equity, + Future, + FutureOption, + Option, + TickSize, +) from tastytrade.metrics import MarketMetricInfo, get_market_metrics from tastytrade.order import ( InstrumentType, @@ -26,8 +34,10 @@ RenewableSession, conditional_color, get_confirmation, + listen_events, print_error, print_warning, + round_to_tick_size, ) @@ -106,7 +116,7 @@ async def positions(all: bool = False): ] equity_symbols = [ p.symbol for p in positions if p.instrument_type == InstrumentType.EQUITY - ] + ] + [o.underlying_symbol for o in options] equities = Equity.get_equities(sesh, equity_symbols) equity_dict = {e.symbol: e for e in equities} all_symbols = ( @@ -120,30 +130,30 @@ async def positions(all: bool = False): ) + greeks_symbols ) - # get greeks for options - greeks_dict: dict[str, Greeks] = {} - summary_dict: dict[str, Decimal] = {} + all_symbols = [s for s in all_symbols if s] async with DXLinkStreamer(sesh) as streamer: - if greeks_symbols != []: - await streamer.subscribe(Greeks, greeks_symbols) - # TODO: handle empty - await streamer.subscribe(Summary, all_symbols) # type: ignore + greeks_task = asyncio.create_task( + listen_events(greeks_symbols, Greeks, streamer) + ) + summary_task = asyncio.create_task( + listen_events(all_symbols, Summary, streamer) + ) + await asyncio.gather(greeks_task, summary_task) await streamer.subscribe(Trade, ["SPY"]) - if greeks_symbols != []: - async for greeks in streamer.listen(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.event_symbol] = summary.prev_day_close_price or ZERO - if len(summary_dict) == len(all_symbols): - break + greeks_dict = greeks_task.result() + summary_dict = { + k: v.prev_day_close_price or ZERO for k, v in summary_task.result().items() + } 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)) metrics = get_market_metrics(sesh, list(tt_symbols)) - metrics_dict = {metric.symbol: metric for metric in metrics} + metrics_dict = defaultdict( + lambda: MarketMetricInfo(symbol="", market_cap=ZERO, updated_at=datetime.now()) + ) + for metric in metrics: + metrics_dict[metric.symbol] = metric table_show_mark = sesh.config.getboolean( "portfolio.positions", "show-mark-price", fallback=False @@ -182,10 +192,9 @@ async def positions(all: bool = False): closing: dict[int, TradeableTastytradeJsonDataclass] = {} for i, pos in enumerate(positions): row = [f"{i+1}"] - mark = pos.mark or 0 - mark_price = pos.mark_price or 0 + mark = pos.mark or ZERO + mark_price = pos.mark_price or ZERO m = 1 if pos.quantity_direction == "Long" else -1 - # mark_price = mark / pos.quantity if all: row.append(account_dict[pos.account_number]) # type: ignore net_liq = Decimal(mark * m) @@ -199,12 +208,13 @@ async def positions(all: bool = False): theta = greeks_dict[o.streamer_symbol].theta * 100 * m gamma = greeks_dict[o.streamer_symbol].gamma * 100 * m metrics = metrics_dict[o.underlying_symbol] + ticks = equity_dict[o.underlying_symbol].option_tick_sizes or [] beta = metrics.beta or 0 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 + trade_price = pos.average_open_price + pnl = (mark_price - trade_price) * m * pos.multiplier day_change = mark_price - summary_dict[o.streamer_symbol] pnl_day = day_change * pos.quantity * pos.multiplier elif pos.instrument_type == InstrumentType.FUTURE_OPTION: @@ -215,6 +225,7 @@ async def positions(all: bool = False): gamma = greeks_dict[o.streamer_symbol].gamma * 100 * m # BWD = beta * stock price * delta / index price f = futures_dict[o.underlying_symbol] + ticks = f.option_tick_sizes or [] metrics = metrics_dict[o.root_symbol] indicators = get_indicators(today, metrics) bwd = ( @@ -223,8 +234,8 @@ async def positions(all: bool = False): 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 + trade_price = pos.average_open_price + pnl = (mark_price - trade_price) * m * pos.multiplier day_change = mark_price - summary_dict[o.streamer_symbol] pnl_day = day_change * pos.quantity * pos.multiplier elif pos.instrument_type == InstrumentType.EQUITY: @@ -234,6 +245,7 @@ async def positions(all: bool = False): # BWD = beta * stock price * delta / index price metrics = metrics_dict[pos.symbol] e = equity_dict[pos.symbol] + ticks = e.tick_sizes or [] closing[i + 1] = e beta = metrics.beta or 0 indicators = get_indicators(today, metrics) @@ -248,16 +260,17 @@ async def positions(all: bool = False): gamma = 0 delta = pos.quantity * m * 100 f = futures_dict[pos.symbol] + ticks = f.tick_sizes or [] closing[i + 1] = f # 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 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 + trade_price = pos.average_open_price + pnl = (mark_price - trade_price) * pos.quantity * m * f.notional_multiplier day_change = mark_price - summary_dict[f.streamer_symbol] - pnl_day = day_change * pos.quantity * pos.multiplier + pnl_day = day_change * pos.quantity * f.notional_multiplier net_liq = pnl_day elif pos.instrument_type == InstrumentType.CRYPTOCURRENCY: theta = 0 @@ -270,11 +283,12 @@ async def positions(all: bool = False): indicators = "" pos.quantity = round(pos.quantity, 2) c = crypto_dict[pos.symbol] + ticks = [TickSize(value=c.tick_size)] closing[i + 1] = c day_change = mark_price - summary_dict[c.streamer_symbol] # type: ignore pnl_day = day_change * pos.quantity * pos.multiplier else: - print( + print_warning( f"Skipping {pos.symbol}, unknown instrument type " f"{pos.instrument_type}!" ) @@ -294,9 +308,9 @@ async def positions(all: bool = False): ] ) if table_show_mark: - row.append(f"${mark_price:.2f}") + row.append(f"${round_to_tick_size(mark_price, ticks)}") if table_show_trade: - row.append(f"${trade_price:.2f}") + row.append(f"${round_to_tick_size(trade_price, ticks)}") row.append(f"{ivr:.1f}" if ivr else "--") if table_show_delta: row.append(f"{delta:.2f}") diff --git a/ttcli/utils.py b/ttcli/utils.py index fa72558..5c9a086 100644 --- a/ttcli/utils.py +++ b/ttcli/utils.py @@ -52,13 +52,15 @@ 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 + return price async def listen_events( dxfeeds: list[str], event_class: Type[U], streamer: DXLinkStreamer ) -> dict[str, U]: event_dict = {} + if not dxfeeds: + return event_dict await streamer.subscribe(event_class, dxfeeds) async for event in streamer.listen(event_class): event_dict[event.event_symbol] = event diff --git a/uv.lock b/uv.lock index 2ee244f..96b4e60 100644 --- a/uv.lock +++ b/uv.lock @@ -627,6 +627,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, ] +[[package]] +name = "py-gnuplot" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/ce/847095b5c8d4141a7136065ad90ea21faeeb9f246327bc4053fce867e56a/py-gnuplot-1.2.1.tar.gz", hash = "sha256:39ee63ccd4990e90278bbd183a1c619e271eeea596312c8dc03ae0eb78d3ac5d", size = 32248 } + [[package]] name = "pycparser" version = "2.22" @@ -934,7 +943,7 @@ wheels = [ [[package]] name = "tastytrade" -version = "9.3" +version = "9.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -942,14 +951,14 @@ dependencies = [ { name = "pydantic" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/2f/007a3c34053faf446dbab8b4fdb8a6225e2cc7ebb308c112cf12f7683ec6/tastytrade-9.3.tar.gz", hash = "sha256:16497a1074cfb9573feb32cb06927dd70c9afc45968e468e4b1e82bc78dee2a9", size = 118037 } +sdist = { url = "https://files.pythonhosted.org/packages/9c/dc/78ad5353463c83e3874c4f2efe6393838b1381a9a3077b1dc0ae7ab6e29c/tastytrade-9.6.tar.gz", hash = "sha256:82bc6a9ef7097f28602e0917ed88ffe7084d9a074efd9ecf5e979bf92adbaae0", size = 115201 } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/e9/099a81451ccaef1acc8d42fbcae68f968e4ef46914094a7b83ec6a1e5e9d/tastytrade-9.3-py3-none-any.whl", hash = "sha256:4eac9e998d7950dc5335f3fc1f04cbc3ea46f9b35b63d42e215c2816ce6c0f9e", size = 47771 }, + { url = "https://files.pythonhosted.org/packages/79/7c/33b58981dff059c946244a40e01d3d764aed13a885d9b70d790b8914bfcc/tastytrade-9.6-py3-none-any.whl", hash = "sha256:7940d07161c698870cbff9cfdee56a9e63a13a34d1838a93cec2d338455861d0", size = 49778 }, ] [[package]] name = "tastytrade-cli" -version = "0.3" +version = "0.5" source = { editable = "." } dependencies = [ { name = "asyncclick" }, @@ -957,6 +966,11 @@ dependencies = [ { name = "tastytrade" }, ] +[package.optional-dependencies] +plot = [ + { name = "py-gnuplot" }, +] + [package.dev-dependencies] dev = [ { name = "ipykernel" }, @@ -967,8 +981,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "asyncclick", specifier = ">=8.1.7.2" }, + { name = "py-gnuplot", marker = "extra == 'plot'", specifier = ">=1.2.1" }, { name = "rich", specifier = ">=13.8.1" }, - { name = "tastytrade", specifier = ">=9.3" }, + { name = "tastytrade", specifier = ">=9.6" }, ] [package.metadata.requires-dev]