tastytrade-cli 0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Graeme Holliday
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.1
2
+ Name: tastytrade-cli
3
+ Version: 0.1
4
+ Summary: An easy-to-use command line interface for Tastytrade!
5
+ Home-page: https://github.com/tastyware/tastytrade-cli
6
+ Author: Graeme Holliday
7
+ Author-email: graeme.holliday@pm.me
8
+ License: MIT
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: asyncclick>=8.1.7.2
12
+ Requires-Dist: rich>=13.7.1
13
+ Requires-Dist: tastytrade>=7.7
14
+
15
+ # tastytrade-cli
16
+
17
+ An easy-to-use command line interface for Tastytrade!
18
+
19
+ ![Peek2024-07-0120-35-ezgif com-speed](https://github.com/tastyware/tastytrade-cli/assets/4185684/3d00731c-8f5e-40c5-973a-0f0357637083)
20
+
21
+ ## Installation
22
+
23
+ ```
24
+ $ pip install tastytrade-cli
25
+ ```
26
+
27
+ > [!WARNING]
28
+ > The CLI is still under active development. Please report any bugs, and contributions are always welcome!
29
+
30
+ ## Usage
31
+
32
+ Available commands:
33
+ ```
34
+ tt option buy, sell, and analyze options
35
+ ```
36
+ Unavailable commands pending development:
37
+ ```
38
+ tt crypto buy, sell, and analyze cryptocurrencies
39
+ tt future buy, sell, and analyze futures
40
+ tt stock buy, sell, and analyze stock
41
+ tt order view, replace, and cancel orders
42
+ tt pf (portfolio) view statistics and risk metrics for your portfolio
43
+ tt wl (watchlist) view current prices and other data for symbols in your watchlists
44
+ ```
45
+ For more options, run `tt --help` or `tt <subcommand> --help`.
46
+
47
+ ## Development/Contributing
48
+
49
+ This project includes a number of helpers in the `Makefile` to streamline common development tasks.
50
+
51
+ Creating a virtualenv for development:
52
+ ```
53
+ $ make venv
54
+ $ source .venv/bin/activate
55
+ ```
56
+
57
+ It's usually a good idea to make sure you're passing tests locally before submitting a PR:
58
+ ```
59
+ $ make lint
60
+ ```
61
+
62
+ If you have a feature suggestion, find a bug, or would like to contribute, feel free to open an issue or create a pull request.
63
+
64
+ ## Disclaimer
65
+
66
+ tastyworks and tastytrade are not affiliated with the makers of this program and do not endorse this product. This program does not provide investment, tax, or legal advice. Stock trading involves risk and is not suitable for all investors. Options involve risk and are not suitable for all investors as the special risks inherent to options trading may expose investors to potentially significant losses. Futures and futures options trading is speculative and is not suitable for all investors. Cryptocurrency trading is speculative and is not suitable for all investors.
@@ -0,0 +1,52 @@
1
+ # tastytrade-cli
2
+
3
+ An easy-to-use command line interface for Tastytrade!
4
+
5
+ ![Peek2024-07-0120-35-ezgif com-speed](https://github.com/tastyware/tastytrade-cli/assets/4185684/3d00731c-8f5e-40c5-973a-0f0357637083)
6
+
7
+ ## Installation
8
+
9
+ ```
10
+ $ pip install tastytrade-cli
11
+ ```
12
+
13
+ > [!WARNING]
14
+ > The CLI is still under active development. Please report any bugs, and contributions are always welcome!
15
+
16
+ ## Usage
17
+
18
+ Available commands:
19
+ ```
20
+ tt option buy, sell, and analyze options
21
+ ```
22
+ Unavailable commands pending development:
23
+ ```
24
+ tt crypto buy, sell, and analyze cryptocurrencies
25
+ tt future buy, sell, and analyze futures
26
+ tt stock buy, sell, and analyze stock
27
+ tt order view, replace, and cancel orders
28
+ tt pf (portfolio) view statistics and risk metrics for your portfolio
29
+ tt wl (watchlist) view current prices and other data for symbols in your watchlists
30
+ ```
31
+ For more options, run `tt --help` or `tt <subcommand> --help`.
32
+
33
+ ## Development/Contributing
34
+
35
+ This project includes a number of helpers in the `Makefile` to streamline common development tasks.
36
+
37
+ Creating a virtualenv for development:
38
+ ```
39
+ $ make venv
40
+ $ source .venv/bin/activate
41
+ ```
42
+
43
+ It's usually a good idea to make sure you're passing tests locally before submitting a PR:
44
+ ```
45
+ $ make lint
46
+ ```
47
+
48
+ If you have a feature suggestion, find a bug, or would like to contribute, feel free to open an issue or create a pull request.
49
+
50
+ ## Disclaimer
51
+
52
+ tastyworks and tastytrade are not affiliated with the makers of this program and do not endorse this product. This program does not provide investment, tax, or legal advice. Stock trading involves risk and is not suitable for all investors. Options involve risk and are not suitable for all investors as the special risks inherent to options trading may expose investors to potentially significant losses. Futures and futures options trading is speculative and is not suitable for all investors. Cryptocurrency trading is speculative and is not suitable for all investors.
@@ -0,0 +1,20 @@
1
+ [general]
2
+ # username = foo
3
+ # password = bar
4
+ # default-account = example
5
+
6
+ [portfolio]
7
+ bp-target-percent-vix-low = 15
8
+ bp-target-percent-vix-high = 55
9
+ bp-target-percent-variation = 5
10
+ portfolio-delta-target = 0
11
+ portfolio-delta-variation = 5
12
+
13
+ [order]
14
+ bp-warn-above-percent = 5
15
+
16
+ [option]
17
+ chain-show-delta = true
18
+ chain-show-volume = false
19
+ chain-show-open-interest = false
20
+ chain-show-theta = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,29 @@
1
+ from setuptools import find_packages, setup
2
+
3
+ f = open('README.md', 'r')
4
+ LONG_DESCRIPTION = f.read()
5
+ f.close()
6
+
7
+ setup(
8
+ name='tastytrade-cli',
9
+ version='0.1',
10
+ description='An easy-to-use command line interface for Tastytrade!',
11
+ long_description=LONG_DESCRIPTION,
12
+ long_description_content_type='text/markdown',
13
+ author='Graeme Holliday',
14
+ author_email='graeme.holliday@pm.me',
15
+ url='https://github.com/tastyware/tastytrade-cli',
16
+ license='MIT',
17
+ install_requires=[
18
+ 'asyncclick>=8.1.7.2',
19
+ 'rich>=13.7.1',
20
+ 'tastytrade>=7.7',
21
+ ],
22
+ data_files = [('etc', ['etc/ttcli.cfg'])],
23
+ packages=find_packages(exclude=['ez_setup']),
24
+ include_package_data=True,
25
+ entry_points="""
26
+ [console_scripts]
27
+ tt = ttcli.app:main
28
+ """
29
+ )
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.1
2
+ Name: tastytrade-cli
3
+ Version: 0.1
4
+ Summary: An easy-to-use command line interface for Tastytrade!
5
+ Home-page: https://github.com/tastyware/tastytrade-cli
6
+ Author: Graeme Holliday
7
+ Author-email: graeme.holliday@pm.me
8
+ License: MIT
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: asyncclick>=8.1.7.2
12
+ Requires-Dist: rich>=13.7.1
13
+ Requires-Dist: tastytrade>=7.7
14
+
15
+ # tastytrade-cli
16
+
17
+ An easy-to-use command line interface for Tastytrade!
18
+
19
+ ![Peek2024-07-0120-35-ezgif com-speed](https://github.com/tastyware/tastytrade-cli/assets/4185684/3d00731c-8f5e-40c5-973a-0f0357637083)
20
+
21
+ ## Installation
22
+
23
+ ```
24
+ $ pip install tastytrade-cli
25
+ ```
26
+
27
+ > [!WARNING]
28
+ > The CLI is still under active development. Please report any bugs, and contributions are always welcome!
29
+
30
+ ## Usage
31
+
32
+ Available commands:
33
+ ```
34
+ tt option buy, sell, and analyze options
35
+ ```
36
+ Unavailable commands pending development:
37
+ ```
38
+ tt crypto buy, sell, and analyze cryptocurrencies
39
+ tt future buy, sell, and analyze futures
40
+ tt stock buy, sell, and analyze stock
41
+ tt order view, replace, and cancel orders
42
+ tt pf (portfolio) view statistics and risk metrics for your portfolio
43
+ tt wl (watchlist) view current prices and other data for symbols in your watchlists
44
+ ```
45
+ For more options, run `tt --help` or `tt <subcommand> --help`.
46
+
47
+ ## Development/Contributing
48
+
49
+ This project includes a number of helpers in the `Makefile` to streamline common development tasks.
50
+
51
+ Creating a virtualenv for development:
52
+ ```
53
+ $ make venv
54
+ $ source .venv/bin/activate
55
+ ```
56
+
57
+ It's usually a good idea to make sure you're passing tests locally before submitting a PR:
58
+ ```
59
+ $ make lint
60
+ ```
61
+
62
+ If you have a feature suggestion, find a bug, or would like to contribute, feel free to open an issue or create a pull request.
63
+
64
+ ## Disclaimer
65
+
66
+ tastyworks and tastytrade are not affiliated with the makers of this program and do not endorse this product. This program does not provide investment, tax, or legal advice. Stock trading involves risk and is not suitable for all investors. Options involve risk and are not suitable for all investors as the special risks inherent to options trading may expose investors to potentially significant losses. Futures and futures options trading is speculative and is not suitable for all investors. Cryptocurrency trading is speculative and is not suitable for all investors.
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ setup.py
4
+ etc/ttcli.cfg
5
+ tastytrade_cli.egg-info/PKG-INFO
6
+ tastytrade_cli.egg-info/SOURCES.txt
7
+ tastytrade_cli.egg-info/dependency_links.txt
8
+ tastytrade_cli.egg-info/entry_points.txt
9
+ tastytrade_cli.egg-info/requires.txt
10
+ tastytrade_cli.egg-info/top_level.txt
11
+ ttcli/__init__.py
12
+ ttcli/app.py
13
+ ttcli/option.py
14
+ ttcli/portfolio.py
15
+ ttcli/utils.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tt = ttcli.app:main
@@ -0,0 +1,3 @@
1
+ asyncclick>=8.1.7.2
2
+ rich>=13.7.1
3
+ tastytrade>=7.7
File without changes
@@ -0,0 +1,25 @@
1
+ import asyncio
2
+ import sys
3
+
4
+ import asyncclick as click
5
+
6
+ from ttcli.option import option
7
+ from ttcli.portfolio import portfolio
8
+ from ttcli.utils import CONTEXT_SETTINGS, VERSION, logger
9
+
10
+
11
+ @click.group(context_settings=CONTEXT_SETTINGS)
12
+ @click.version_option(VERSION)
13
+ async def app():
14
+ pass
15
+
16
+
17
+ def main():
18
+ if sys.version_info[0] == 3 and sys.version_info[1] >= 8 and sys.platform.startswith('win'):
19
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
20
+ logger.debug('Using Windows-specific event loop policy')
21
+
22
+ app.add_command(option)
23
+ app.add_command(portfolio, name='pf')
24
+
25
+ app(_anyio_backend='asyncio')
@@ -0,0 +1,706 @@
1
+ from datetime import date
2
+ from decimal import Decimal
3
+ from typing import Optional
4
+
5
+ import asyncclick as click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from tastytrade import DXLinkStreamer
9
+ from tastytrade.dxfeed import EventType, Greeks, Quote
10
+ from tastytrade.instruments import Future, NestedFutureOptionChain, NestedFutureOptionChainExpiration, NestedOptionChain, NestedOptionChainExpiration, Option
11
+ from tastytrade.order import (NewOrder, OrderAction, OrderTimeInForce,
12
+ OrderType, PriceEffect)
13
+ from tastytrade.utils import get_tasty_monthly
14
+
15
+ from ttcli.utils import (ZERO, RenewableSession, get_confirmation, is_monthly,
16
+ print_error, print_warning, test_order_handle_errors)
17
+
18
+
19
+ def choose_expiration(
20
+ chain: NestedOptionChain,
21
+ include_weeklies: bool = False
22
+ ) -> NestedOptionChainExpiration:
23
+ exps = [e for e in chain.expirations]
24
+ if not include_weeklies:
25
+ exps = [e for e in exps if is_monthly(e.expiration_date)]
26
+ default = get_tasty_monthly()
27
+ default_option = None
28
+ for i, exp in enumerate(exps):
29
+ if exp.expiration_date == default:
30
+ default_option = exp
31
+ print(f'{i + 1}) {exp.expiration_date} (default)')
32
+ else:
33
+ print(f'{i + 1}) {exp.expiration_date}')
34
+ choice = 0
35
+ while choice not in range(1, len(exps) + 1):
36
+ try:
37
+ raw = input('Please choose an expiration: ')
38
+ choice = int(raw)
39
+ except ValueError:
40
+ if not raw:
41
+ return default_option
42
+
43
+ return exps[choice - 1]
44
+
45
+
46
+ def choose_futures_expiration(
47
+ chain: NestedFutureOptionChain,
48
+ include_weeklies: bool = False
49
+ ) -> NestedFutureOptionChainExpiration:
50
+ chain = chain.option_chains[0]
51
+ if include_weeklies:
52
+ exps = [e for e in chain.expirations]
53
+ else:
54
+ exps = [e for e in chain.expirations if e.expiration_type != 'Weekly']
55
+ for i, exp in enumerate(exps):
56
+ if i == 0:
57
+ print(f'{i + 1}) {exp.expiration_date} [{exp.underlying_symbol}] (default)')
58
+ else:
59
+ print(f'{i + 1}) {exp.expiration_date} [{exp.underlying_symbol}]')
60
+ choice = 0
61
+ while choice not in range(1, len(exps) + 1):
62
+ try:
63
+ raw = input('Please choose an expiration: ')
64
+ choice = int(raw)
65
+ except ValueError:
66
+ if not raw:
67
+ return exps[0]
68
+
69
+ return exps[choice - 1]
70
+
71
+
72
+ async def listen_quotes(
73
+ n_quotes: int,
74
+ streamer: DXLinkStreamer,
75
+ skip: str | None = None
76
+ ) -> dict[str, Quote]:
77
+ quote_dict = {}
78
+ async for quote in streamer.listen(EventType.QUOTE):
79
+ if quote.eventSymbol != skip:
80
+ quote_dict[quote.eventSymbol] = quote
81
+ if len(quote_dict) == n_quotes:
82
+ return quote_dict
83
+
84
+
85
+ async def listen_greeks(
86
+ n_greeks: int,
87
+ streamer: DXLinkStreamer
88
+ ) -> dict[str, Greeks]:
89
+ greeks_dict = {}
90
+ async for greeks in streamer.listen(EventType.GREEKS):
91
+ greeks_dict[greeks.eventSymbol] = greeks
92
+ if len(greeks_dict) == n_greeks:
93
+ return greeks_dict
94
+
95
+
96
+ async def listen_summaries(
97
+ n_summaries: int,
98
+ streamer: DXLinkStreamer
99
+ ) -> dict[str, Quote]:
100
+ summary_dict = {}
101
+ async for summary in streamer.listen(EventType.SUMMARY):
102
+ summary_dict[summary.eventSymbol] = summary
103
+ if len(summary_dict) == n_summaries:
104
+ return summary_dict
105
+
106
+
107
+ async def listen_trades(
108
+ n_trades: int,
109
+ streamer: DXLinkStreamer
110
+ ) -> dict[str, Quote]:
111
+ trade_dict = {}
112
+ async for trade in streamer.listen(EventType.TRADE):
113
+ trade_dict[trade.eventSymbol] = trade
114
+ if len(trade_dict) == n_trades:
115
+ return trade_dict
116
+
117
+
118
+ @click.group(chain=True, help='Buy, sell, and analyze options.')
119
+ async def option():
120
+ pass
121
+
122
+
123
+ @option.command(help='Buy or sell calls with the given parameters.')
124
+ @click.option('-s', '--strike', type=Decimal, help='The chosen strike for the option.')
125
+ @click.option('-d', '--delta', type=int, help='The chosen delta for the option.')
126
+ @click.option('-w', '--width', type=int, help='Turns the order into a spread with the given width.')
127
+ @click.option('--gtc', is_flag=True, help='Place a GTC order instead of a day order.')
128
+ @click.option('--weeklies', is_flag=True, help='Show all expirations, not just monthlies.')
129
+ @click.argument('symbol', type=str)
130
+ @click.argument('quantity', type=int)
131
+ async def call(symbol: str, quantity: int, strike: Optional[Decimal] = None, width: Optional[int] = None,
132
+ gtc: bool = False, weeklies: bool = False, delta: Optional[int] = None):
133
+ if strike is not None and delta is not None:
134
+ print_error('Must specify either delta or strike, but not both.')
135
+ return
136
+ elif not strike and not delta:
137
+ print_error('Please specify either delta or strike for the option.')
138
+ return
139
+ elif delta is not None and abs(delta) > 99:
140
+ print_error('Delta value is too high, -99 <= delta <= 99')
141
+ return
142
+
143
+ sesh = RenewableSession()
144
+ chain = NestedOptionChain.get_chain(sesh, symbol)
145
+ subchain = choose_expiration(chain, weeklies)
146
+
147
+ async with DXLinkStreamer(sesh) as streamer:
148
+ if not strike:
149
+ dxfeeds = [s.call_streamer_symbol for s in subchain.strikes]
150
+ await streamer.subscribe(EventType.GREEKS, dxfeeds)
151
+ greeks_dict = await listen_greeks(len(dxfeeds), streamer)
152
+ greeks = list(greeks_dict.values())
153
+
154
+ lowest = 100
155
+ selected = None
156
+ for g in greeks:
157
+ diff = abs(g.delta * Decimal(100) - delta)
158
+ if diff < lowest:
159
+ selected = g
160
+ lowest = diff
161
+ # set strike with the closest delta
162
+ strike = next(s.strike_price for s in subchain.strikes
163
+ if s.call_streamer_symbol == selected.eventSymbol)
164
+
165
+ if width:
166
+ spread_strike = next(s for s in subchain.strikes if s.strike_price == strike + width)
167
+ await streamer.subscribe(EventType.QUOTE, [selected.eventSymbol, spread_strike.call_streamer_symbol])
168
+ quote_dict = await listen_quotes(2, streamer)
169
+ bid = quote_dict[selected.eventSymbol].bidPrice - quote_dict[spread_strike.call_streamer_symbol].askPrice
170
+ ask = quote_dict[selected.eventSymbol].askPrice - quote_dict[spread_strike.call_streamer_symbol].bidPrice
171
+ mid = (bid + ask) / Decimal(2)
172
+ else:
173
+ await streamer.subscribe(EventType.QUOTE, [selected.eventSymbol])
174
+ quote = await streamer.get_event(EventType.QUOTE)
175
+ bid = quote.bidPrice
176
+ ask = quote.askPrice
177
+ mid = (bid + ask) / Decimal(2)
178
+
179
+ console = Console()
180
+ if width:
181
+ table = Table(show_header=True, header_style='bold', title_style='bold',
182
+ title=f'Quote for {symbol} call spread {subchain.expiration_date}')
183
+ else:
184
+ table = Table(show_header=True, header_style='bold', title_style='bold',
185
+ title=f'Quote for {symbol} {strike}C {subchain.expiration_date}')
186
+ table.add_column('Bid', style='green', width=8, justify='center')
187
+ table.add_column('Mid', width=8, justify='center')
188
+ table.add_column('Ask', style='red', width=8, justify='center')
189
+ table.add_row(f'{bid:.2f}', f'{mid:.2f}', f'{ask:.2f}')
190
+ console.print(table)
191
+
192
+ price = input('Please enter a limit price per quantity (default mid): ')
193
+ if not price:
194
+ price = round(mid, 2)
195
+ price = Decimal(price)
196
+
197
+ short_symbol = next(s.call for s in subchain.strikes if s.strike_price == strike)
198
+ if width:
199
+ res = Option.get_options(sesh, [short_symbol, spread_strike.call])
200
+ res.sort(key=lambda x: x.strike_price)
201
+ legs = [
202
+ res[0].build_leg(abs(quantity), OrderAction.SELL_TO_OPEN if quantity < 0 else OrderAction.BUY_TO_OPEN),
203
+ res[1].build_leg(abs(quantity), OrderAction.BUY_TO_OPEN if quantity < 0 else OrderAction.SELL_TO_OPEN)
204
+ ]
205
+ else:
206
+ call = Option.get_option(sesh, short_symbol)
207
+ legs = [call.build_leg(abs(quantity), OrderAction.SELL_TO_OPEN if quantity < 0 else OrderAction.BUY_TO_OPEN)]
208
+ order = NewOrder(
209
+ time_in_force=OrderTimeInForce.GTC if gtc else OrderTimeInForce.DAY,
210
+ order_type=OrderType.LIMIT,
211
+ legs=legs,
212
+ price=price,
213
+ price_effect=PriceEffect.CREDIT if quantity < 0 else PriceEffect.DEBIT
214
+ )
215
+ acc = sesh.get_account()
216
+
217
+ data = test_order_handle_errors(acc, sesh, order)
218
+ if data is None:
219
+ return
220
+
221
+ nl = acc.get_balances(sesh).net_liquidating_value
222
+ bp = data.buying_power_effect.change_in_buying_power
223
+ percent = bp / nl * Decimal(100)
224
+ fees = data.fee_calculation.total_fees
225
+
226
+ table = Table(show_header=True, header_style='bold', title_style='bold', title='Order Review')
227
+ table.add_column('Quantity', width=8, justify='center')
228
+ table.add_column('Symbol', width=8, justify='center')
229
+ table.add_column('Strike', width=8, justify='center')
230
+ table.add_column('Type', width=8, justify='center')
231
+ table.add_column('Expiration', width=10, justify='center')
232
+ table.add_column('Price', width=8, justify='center')
233
+ table.add_column('BP', width=8, justify='center')
234
+ table.add_column('BP %', width=8, justify='center')
235
+ table.add_column('Fees', width=8, justify='center')
236
+ table.add_row(f'{quantity:+}', symbol, f'${strike:.2f}', 'CALL', f'{subchain.expiration_date}', f'${price:.2f}',
237
+ f'${bp:.2f}', f'{percent:.2f}%', f'${fees:.2f}')
238
+ if width:
239
+ table.add_row(f'{-quantity:+}', symbol, f'${spread_strike.strike_price:.2f}',
240
+ 'CALL', f'{subchain.expiration_date}', '-', '-', '-', '-')
241
+ console.print(table)
242
+
243
+ if data.warnings:
244
+ for warning in data.warnings:
245
+ print_warning(warning.message)
246
+ warn_percent = sesh.config.getint('order', 'bp-warn-above-percent', fallback=None)
247
+ if warn_percent and percent > warn_percent:
248
+ print_warning(f'Buying power usage is above target of {warn_percent}%!')
249
+ if get_confirmation('Send order? Y/n '):
250
+ acc.place_order(sesh, order, dry_run=False)
251
+
252
+
253
+ @option.command(help='Buy or sell puts with the given parameters.')
254
+ @click.option('-s', '--strike', type=Decimal, help='The chosen strike for the option.')
255
+ @click.option('-d', '--delta', type=int, help='The chosen delta for the option.')
256
+ @click.option('-w', '--width', type=int, help='Turns the order into a spread with the given width.')
257
+ @click.option('--gtc', is_flag=True, help='Place a GTC order instead of a day order.')
258
+ @click.option('--weeklies', is_flag=True, help='Show all expirations, not just monthlies.')
259
+ @click.argument('symbol', type=str)
260
+ @click.argument('quantity', type=int)
261
+ async def put(symbol: str, quantity: int, strike: Optional[int] = None, width: Optional[int] = None,
262
+ gtc: bool = False, weeklies: bool = False, delta: Optional[int] = None):
263
+ if strike is not None and delta is not None:
264
+ print_error('Must specify either delta or strike, but not both.')
265
+ return
266
+ elif not strike and not delta:
267
+ print_error('Please specify either delta or strike for the option.')
268
+ return
269
+ elif delta is not None and abs(delta) > 99:
270
+ print_error('Delta value is too high, -99 <= delta <= 99')
271
+ return
272
+
273
+ sesh = RenewableSession()
274
+ chain = NestedOptionChain.get_chain(sesh, symbol)
275
+ subchain = choose_expiration(chain, weeklies)
276
+
277
+ async with DXLinkStreamer(sesh) as streamer:
278
+ if not strike:
279
+ dxfeeds = [s.put_streamer_symbol for s in subchain.strikes]
280
+ await streamer.subscribe(EventType.GREEKS, dxfeeds)
281
+ greeks_dict = await listen_greeks(len(dxfeeds), streamer)
282
+ greeks = list(greeks_dict.values())
283
+
284
+ lowest = 100
285
+ selected = None
286
+ for g in greeks:
287
+ diff = abs(g.delta * Decimal(100) + delta)
288
+ if diff < lowest:
289
+ selected = g
290
+ lowest = diff
291
+ # set strike with the closest delta
292
+ strike = next(s.strike_price for s in subchain.strikes
293
+ if s.put_streamer_symbol == selected.eventSymbol)
294
+
295
+ if width:
296
+ spread_strike = next(s for s in subchain.strikes if s.strike_price == strike - width)
297
+ await streamer.subscribe(EventType.QUOTE, [selected.eventSymbol, spread_strike.put_streamer_symbol])
298
+ quote_dict = await listen_quotes(2, streamer)
299
+ bid = quote_dict[selected.eventSymbol].bidPrice - quote_dict[spread_strike.put_streamer_symbol].askPrice
300
+ ask = quote_dict[selected.eventSymbol].askPrice - quote_dict[spread_strike.put_streamer_symbol].bidPrice
301
+ mid = (bid + ask) / Decimal(2)
302
+ else:
303
+ await streamer.subscribe(EventType.QUOTE, [selected.eventSymbol])
304
+ quote = await streamer.get_event(EventType.QUOTE)
305
+ bid = quote.bidPrice
306
+ ask = quote.askPrice
307
+ mid = (bid + ask) / Decimal(2)
308
+
309
+ console = Console()
310
+ if width:
311
+ table = Table(show_header=True, header_style='bold', title_style='bold',
312
+ title=f'Quote for {symbol} put spread {subchain.expiration_date}')
313
+ else:
314
+ table = Table(show_header=True, header_style='bold', title_style='bold',
315
+ title=f'Quote for {symbol} {strike}P {subchain.expiration_date}')
316
+ table.add_column('Bid', style='green', width=8, justify='center')
317
+ table.add_column('Mid', width=8, justify='center')
318
+ table.add_column('Ask', style='red', width=8, justify='center')
319
+ table.add_row(f'{bid:.2f}', f'{mid:.2f}', f'{ask:.2f}')
320
+ console.print(table)
321
+
322
+ price = input('Please enter a limit price per quantity (default mid): ')
323
+ if not price:
324
+ price = round(mid, 2)
325
+ price = Decimal(price)
326
+
327
+ short_symbol = next(s.put for s in subchain.strikes if s.strike_price == strike)
328
+ if width:
329
+ res = Option.get_options(sesh, [short_symbol, spread_strike.put])
330
+ res.sort(key=lambda x: x.strike_price, reverse=True)
331
+ legs = [
332
+ res[0].build_leg(abs(quantity), OrderAction.SELL_TO_OPEN if quantity < 0 else OrderAction.BUY_TO_OPEN),
333
+ res[1].build_leg(abs(quantity), OrderAction.BUY_TO_OPEN if quantity < 0 else OrderAction.SELL_TO_OPEN)
334
+ ]
335
+ else:
336
+ put = Option.get_option(sesh, short_symbol)
337
+ legs = [put.build_leg(abs(quantity), OrderAction.SELL_TO_OPEN if quantity < 0 else OrderAction.BUY_TO_OPEN)]
338
+ order = NewOrder(
339
+ time_in_force=OrderTimeInForce.GTC if gtc else OrderTimeInForce.DAY,
340
+ order_type=OrderType.LIMIT,
341
+ legs=legs,
342
+ price=price,
343
+ price_effect=PriceEffect.CREDIT if quantity < 0 else PriceEffect.DEBIT
344
+ )
345
+ acc = sesh.get_account()
346
+
347
+ data = test_order_handle_errors(acc, sesh, order)
348
+ if data is None:
349
+ return
350
+
351
+ nl = acc.get_balances(sesh).net_liquidating_value
352
+ bp = data.buying_power_effect.change_in_buying_power
353
+ percent = bp / nl * Decimal(100)
354
+ fees = data.fee_calculation.total_fees
355
+
356
+ table = Table(show_header=True, header_style='bold', title_style='bold', title='Order Review')
357
+ table.add_column('Quantity', width=8, justify='center')
358
+ table.add_column('Symbol', width=8, justify='center')
359
+ table.add_column('Strike', width=8, justify='center')
360
+ table.add_column('Type', width=8, justify='center')
361
+ table.add_column('Expiration', width=10, justify='center')
362
+ table.add_column('Price', width=8, justify='center')
363
+ table.add_column('BP', width=8, justify='center')
364
+ table.add_column('BP %', width=8, justify='center')
365
+ table.add_column('Fees', width=8, justify='center')
366
+ table.add_row(f'{quantity:+}', symbol, f'${strike:.2f}', 'PUT', f'{subchain.expiration_date}', f'${price:.2f}',
367
+ f'${bp:.2f}', f'{percent:.2f}%', f'${fees:.2f}')
368
+ if width:
369
+ table.add_row(f'{-quantity:+}', symbol, f'${spread_strike.strike_price:.2f}',
370
+ 'PUT', f'{subchain.expiration_date}', '-', '-', '-', '-')
371
+ console.print(table)
372
+
373
+ if data.warnings:
374
+ for warning in data.warnings:
375
+ print_warning(warning.message)
376
+ warn_percent = sesh.config.getint('order', 'bp-warn-above-percent', fallback=None)
377
+ if warn_percent and percent > warn_percent:
378
+ print_warning(f'Buying power usage is above target of {warn_percent}%!')
379
+ if get_confirmation('Send order? Y/n '):
380
+ acc.place_order(sesh, order, dry_run=False)
381
+
382
+
383
+ @option.command(help='Buy or sell strangles with the given parameters.')
384
+ @click.option('-c', '--call', type=Decimal, help='The chosen strike for the call option.')
385
+ @click.option('-p', '--put', type=Decimal, help='The chosen strike for the put option.')
386
+ @click.option('-d', '--delta', type=int, help='The chosen delta for both options.')
387
+ @click.option('-w', '--width', type=int, help='Turns the order into an iron condor with the given width.')
388
+ @click.option('--gtc', is_flag=True, help='Place a GTC order instead of a day order.')
389
+ @click.option('--weeklies', is_flag=True, help='Show all expirations, not just monthlies.')
390
+ @click.argument('symbol', type=str)
391
+ @click.argument('quantity', type=int)
392
+ async def strangle(symbol: str, quantity: int, call: Optional[Decimal] = None, width: Optional[int] = None,
393
+ gtc: bool = False, weeklies: bool = False, delta: Optional[int] = None, put: Optional[Decimal] = None):
394
+ if (call is not None or put is not None) and delta is not None:
395
+ print_error('Must specify either delta or strike, but not both.')
396
+ return
397
+ elif delta is not None and (call is not None or put is not None):
398
+ print_error('Please specify either delta, or strikes for both options.')
399
+ return
400
+ elif delta is not None and abs(delta) > 99:
401
+ print_error('Delta value is too high, -99 <= delta <= 99')
402
+ return
403
+
404
+ sesh = RenewableSession()
405
+ chain = NestedOptionChain.get_chain(sesh, symbol)
406
+ subchain = choose_expiration(chain, weeklies)
407
+
408
+ async with DXLinkStreamer(sesh) as streamer:
409
+ if delta is not None:
410
+ put_dxf = [s.put_streamer_symbol for s in subchain.strikes]
411
+ call_dxf = [s.call_streamer_symbol for s in subchain.strikes]
412
+ dxfeeds = put_dxf + call_dxf
413
+ await streamer.subscribe(EventType.GREEKS, dxfeeds)
414
+ greeks_dict = await listen_greeks(len(dxfeeds), streamer)
415
+ put_greeks = [v for v in greeks_dict.values() if v.eventSymbol in put_dxf]
416
+ call_greeks = [v for v in greeks_dict.values() if v.eventSymbol in call_dxf]
417
+
418
+ lowest = 100
419
+ selected_put = None
420
+ for g in put_greeks:
421
+ diff = abs(g.delta * Decimal(100) + delta)
422
+ if diff < lowest:
423
+ selected_put = g.eventSymbol
424
+ lowest = diff
425
+ lowest = 100
426
+ selected_call = None
427
+ for g in call_greeks:
428
+ diff = abs(g.delta * Decimal(100) - delta)
429
+ if diff < lowest:
430
+ selected_call = g.eventSymbol
431
+ lowest = diff
432
+ # set strike with the closest delta
433
+ put_strike = next(s for s in subchain.strikes
434
+ if s.put_streamer_symbol == selected_put)
435
+ call_strike = next(s for s in subchain.strikes
436
+ if s.call_streamer_symbol == selected_call)
437
+ else:
438
+ put_strike = next(s for s in subchain.strikes if s.strike_price == put)
439
+ call_strike = next(s for s in subchain.strikes if s.strike_price == call)
440
+
441
+ if width:
442
+ put_spread_strike = next(s for s in subchain.strikes if s.strike_price == put_strike.strike_price - width)
443
+ call_spread_strike = next(s for s in subchain.strikes if s.strike_price == call_strike.strike_price + width)
444
+ await streamer.subscribe(
445
+ EventType.QUOTE,
446
+ [
447
+ call_strike.call_streamer_symbol,
448
+ put_strike.put_streamer_symbol,
449
+ put_spread_strike.put_streamer_symbol,
450
+ call_spread_strike.call_streamer_symbol
451
+ ]
452
+ )
453
+ quote_dict = await listen_quotes(4, streamer)
454
+ bid = (quote_dict[call_strike.call_streamer_symbol].bidPrice +
455
+ quote_dict[put_strike.put_streamer_symbol].bidPrice -
456
+ quote_dict[put_spread_strike.put_streamer_symbol].askPrice -
457
+ quote_dict[call_spread_strike.call_streamer_symbol].askPrice)
458
+ ask = (quote_dict[call_strike.call_streamer_symbol].askPrice +
459
+ quote_dict[put_strike.put_streamer_symbol].askPrice -
460
+ quote_dict[put_spread_strike.put_streamer_symbol].bidPrice -
461
+ quote_dict[call_spread_strike.call_streamer_symbol].bidPrice)
462
+ mid = (bid + ask) / Decimal(2)
463
+ else:
464
+ await streamer.subscribe(EventType.QUOTE, [put_strike.put_streamer_symbol, call_strike.call_streamer_symbol])
465
+ quote_dict = await listen_quotes(2, streamer)
466
+ bid = sum([q.bidPrice for q in quote_dict.values()])
467
+ ask = sum([q.askPrice for q in quote_dict.values()])
468
+ mid = (bid + ask) / Decimal(2)
469
+
470
+ console = Console()
471
+ if width:
472
+ table = Table(show_header=True, header_style='bold', title_style='bold',
473
+ title=f'Quote for {symbol} iron condor {subchain.expiration_date}')
474
+ else:
475
+ table = Table(show_header=True, header_style='bold', title_style='bold',
476
+ title=f'Quote for {symbol} {put_strike.strike_price}/{call_strike.strike_price} strangle {subchain.expiration_date}')
477
+ table.add_column('Bid', style='green', width=8, justify='center')
478
+ table.add_column('Mid', width=8, justify='center')
479
+ table.add_column('Ask', style='red', width=8, justify='center')
480
+ table.add_row(f'{bid:.2f}', f'{mid:.2f}', f'{ask:.2f}')
481
+ console.print(table)
482
+
483
+ price = input('Please enter a limit price per quantity (default mid): ')
484
+ if not price:
485
+ price = round(mid, 2)
486
+ price = Decimal(price)
487
+
488
+ tt_symbols = [put_strike.put, call_strike.call]
489
+ if width:
490
+ tt_symbols += [put_spread_strike.put, call_spread_strike.call]
491
+ options = Option.get_options(sesh, tt_symbols)
492
+ options.sort(key=lambda o: o.strike_price)
493
+ if width:
494
+ legs = [
495
+ options[0].build_leg(abs(quantity), OrderAction.BUY_TO_OPEN if quantity < 0 else OrderAction.SELL_TO_OPEN),
496
+ options[1].build_leg(abs(quantity), OrderAction.SELL_TO_OPEN if quantity < 0 else OrderAction.BUY_TO_OPEN),
497
+ options[2].build_leg(abs(quantity), OrderAction.SELL_TO_OPEN if quantity < 0 else OrderAction.BUY_TO_OPEN),
498
+ options[3].build_leg(abs(quantity), OrderAction.BUY_TO_OPEN if quantity < 0 else OrderAction.SELL_TO_OPEN)
499
+ ]
500
+ else:
501
+ legs = [
502
+ options[0].build_leg(abs(quantity), OrderAction.SELL_TO_OPEN if quantity < 0 else OrderAction.BUY_TO_OPEN),
503
+ options[1].build_leg(abs(quantity), OrderAction.SELL_TO_OPEN if quantity < 0 else OrderAction.BUY_TO_OPEN)
504
+ ]
505
+ order = NewOrder(
506
+ time_in_force=OrderTimeInForce.GTC if gtc else OrderTimeInForce.DAY,
507
+ order_type=OrderType.LIMIT,
508
+ legs=legs,
509
+ price=price,
510
+ price_effect=PriceEffect.CREDIT if quantity < 0 else PriceEffect.DEBIT
511
+ )
512
+ acc = sesh.get_account()
513
+
514
+ data = test_order_handle_errors(acc, sesh, order)
515
+ if data is None:
516
+ return
517
+
518
+ nl = acc.get_balances(sesh).net_liquidating_value
519
+ bp = data.buying_power_effect.change_in_buying_power
520
+ percent = bp / nl * Decimal(100)
521
+ fees = data.fee_calculation.total_fees
522
+
523
+ table = Table(header_style='bold', title_style='bold', title='Order Review')
524
+ table.add_column('Quantity', width=8, justify='center')
525
+ table.add_column('Symbol', width=8, justify='center')
526
+ table.add_column('Strike', width=8, justify='center')
527
+ table.add_column('Type', width=8, justify='center')
528
+ table.add_column('Expiration', width=10, justify='center')
529
+ table.add_column('Price', width=8, justify='center')
530
+ table.add_column('BP', width=8, justify='center')
531
+ table.add_column('BP %', width=8, justify='center')
532
+ table.add_column('Fees', width=8, justify='center')
533
+ table.add_row(
534
+ f'{quantity:+}',
535
+ symbol,
536
+ f'${put_strike.strike_price:.2f}',
537
+ 'PUT',
538
+ f'{subchain.expiration_date}',
539
+ f'${price:.2f}',
540
+ f'${bp:.2f}',
541
+ f'{percent:.2f}%',
542
+ f'${fees:.2f}'
543
+ )
544
+ table.add_row(
545
+ f'{quantity:+}',
546
+ symbol,
547
+ f'${call_strike.strike_price:.2f}',
548
+ 'CALL',
549
+ f'{subchain.expiration_date}',
550
+ '-',
551
+ '-',
552
+ '-',
553
+ '-'
554
+ )
555
+ if width:
556
+ table.add_row(
557
+ f'{-quantity:+}',
558
+ symbol,
559
+ f'${put_spread_strike.strike_price:.2f}',
560
+ 'PUT',
561
+ f'{subchain.expiration_date}',
562
+ '-',
563
+ '-',
564
+ '-',
565
+ '-'
566
+ )
567
+ table.add_row(
568
+ f'{-quantity:+}',
569
+ symbol,
570
+ f'${call_spread_strike.strike_price:.2f}',
571
+ 'CALL',
572
+ f'{subchain.expiration_date}',
573
+ '-',
574
+ '-',
575
+ '-',
576
+ '-'
577
+ )
578
+ console.print(table)
579
+
580
+ if data.warnings:
581
+ for warning in data.warnings:
582
+ print_warning(warning.message)
583
+ warn_percent = sesh.config.getint('order', 'bp-warn-above-percent', fallback=None)
584
+ if warn_percent and percent > warn_percent:
585
+ print_warning(f'Buying power usage is above target of {warn_percent}%!')
586
+ if get_confirmation('Send order? Y/n '):
587
+ acc.place_order(sesh, order, dry_run=False)
588
+
589
+
590
+ @option.command(help='Fetch and display an options chain.')
591
+ @click.option('-w', '--weeklies', is_flag=True,
592
+ help='Show all expirations, not just monthlies.')
593
+ @click.option('-s', '--strikes', type=int, default=8,
594
+ help='The number of strikes to fetch above and below the spot price.')
595
+ @click.argument('symbol', type=str)
596
+ async def chain(symbol: str, strikes: int = 8, weeklies: bool = False):
597
+ sesh = RenewableSession()
598
+ async with DXLinkStreamer(sesh) as streamer:
599
+ if symbol[0] == '/': # futures options
600
+ chain = NestedFutureOptionChain.get_chain(sesh, symbol)
601
+ subchain = choose_futures_expiration(chain, weeklies)
602
+ precision = subchain.tick_sizes[0].value.as_tuple().exponent
603
+ else:
604
+ chain = NestedOptionChain.get_chain(sesh, symbol)
605
+ precision = chain.tick_sizes[0].value.as_tuple().exponent
606
+ subchain = choose_expiration(chain, weeklies)
607
+ precision = abs(precision) if precision < 0 else ZERO
608
+ precision = f'.{precision}f'
609
+
610
+ console = Console()
611
+ table = Table(show_header=True, header_style='bold', title_style='bold',
612
+ title=f'Options chain for {symbol} expiring {subchain.expiration_date}')
613
+
614
+ show_delta = sesh.config.getboolean('option', 'chain-show-delta', fallback=True)
615
+ show_theta = sesh.config.getboolean('option', 'chain-show-theta', fallback=False)
616
+ show_oi = sesh.config.getboolean('option', 'chain-show-open-interest', fallback=False)
617
+ show_volume = sesh.config.getboolean('option', 'chain-show-volume', fallback=False)
618
+ if show_volume:
619
+ table.add_column(u'Volume', justify='right')
620
+ if show_oi:
621
+ table.add_column(u'Open Int', justify='right')
622
+ if show_theta:
623
+ table.add_column(u'Call \u03B8', justify='center')
624
+ if show_delta:
625
+ table.add_column(u'Call \u0394', justify='center')
626
+ table.add_column('Bid', style='green', justify='center')
627
+ table.add_column('Ask', style='red', justify='center')
628
+ table.add_column('Strike', justify='center')
629
+ table.add_column('Bid', style='green', justify='center')
630
+ table.add_column('Ask', style='red', justify='center')
631
+ if show_delta:
632
+ table.add_column(u'Put \u0394', justify='center')
633
+ if show_theta:
634
+ table.add_column(u'Put \u03B8', justify='center')
635
+ if show_oi:
636
+ table.add_column(u'Open Int', justify='right')
637
+ if show_volume:
638
+ table.add_column(u'Volume', justify='right')
639
+
640
+ if symbol[0] == '/': # futures options
641
+ future = Future.get_future(sesh, subchain.underlying_symbol)
642
+ await streamer.subscribe(EventType.QUOTE, [future.streamer_symbol])
643
+ else:
644
+ await streamer.subscribe(EventType.QUOTE, [symbol])
645
+ quote = await streamer.get_event(EventType.QUOTE)
646
+ strike_price = (quote.bidPrice + quote.askPrice) / 2
647
+
648
+ subchain.strikes.sort(key=lambda s: s.strike_price)
649
+ if strikes * 2 < len(subchain.strikes):
650
+ mid_index = 0
651
+ while subchain.strikes[mid_index].strike_price < strike_price:
652
+ mid_index += 1
653
+ all_strikes = subchain.strikes[mid_index - strikes:mid_index + strikes]
654
+ else:
655
+ all_strikes = subchain.strikes
656
+
657
+ dxfeeds = ([s.call_streamer_symbol for s in all_strikes] +
658
+ [s.put_streamer_symbol for s in all_strikes])
659
+ await streamer.subscribe(EventType.QUOTE, dxfeeds)
660
+ await streamer.subscribe(EventType.GREEKS, dxfeeds)
661
+ if show_oi:
662
+ await streamer.subscribe(EventType.SUMMARY, dxfeeds)
663
+ if show_volume:
664
+ await streamer.subscribe(EventType.TRADE, dxfeeds)
665
+
666
+ greeks_dict = await listen_greeks(len(dxfeeds), streamer)
667
+ # take into account the symbol we subscribed to
668
+ quote_dict = await listen_quotes(len(dxfeeds), streamer, skip=symbol if symbol[0] != '/' else future.streamer_symbol)
669
+ if show_oi:
670
+ summary_dict = await listen_summaries(len(dxfeeds), streamer)
671
+ if show_volume:
672
+ trade_dict = await listen_trades(len(dxfeeds), streamer)
673
+
674
+ for i, strike in enumerate(all_strikes):
675
+ put_bid = quote_dict[strike.put_streamer_symbol].bidPrice
676
+ put_ask = quote_dict[strike.put_streamer_symbol].askPrice
677
+ call_bid = quote_dict[strike.call_streamer_symbol].bidPrice
678
+ call_ask = quote_dict[strike.call_streamer_symbol].askPrice
679
+ row = [
680
+ f'{call_bid:{precision}}',
681
+ f'{call_ask:{precision}}',
682
+ f'{strike.strike_price:{precision}}',
683
+ f'{put_bid:{precision}}',
684
+ f'{put_ask:{precision}}'
685
+ ]
686
+ prepend = []
687
+ if show_delta:
688
+ put_delta = int(greeks_dict[strike.put_streamer_symbol].delta * 100)
689
+ call_delta = int(greeks_dict[strike.call_streamer_symbol].delta * 100)
690
+ prepend.append(f'{call_delta:g}')
691
+ row.append(f'{put_delta:g}')
692
+
693
+ if show_theta:
694
+ prepend.append(f'{abs(greeks_dict[strike.put_streamer_symbol].theta):.2f}')
695
+ row.append(f'{abs(greeks_dict[strike.call_streamer_symbol].theta):.2f}')
696
+ if show_oi:
697
+ prepend.append(f'{summary_dict[strike.put_streamer_symbol].openInterest}')
698
+ row.append(f'{summary_dict[strike.call_streamer_symbol].openInterest}')
699
+ if show_volume:
700
+ prepend.append(f'{trade_dict[strike.put_streamer_symbol].dayVolume}')
701
+ row.append(f'{trade_dict[strike.call_streamer_symbol].dayVolume}')
702
+
703
+ prepend.reverse()
704
+ table.add_row(*(prepend + row), end_section=(i == strikes - 1))
705
+
706
+ console.print(table)
@@ -0,0 +1,6 @@
1
+ import asyncclick as click
2
+
3
+
4
+ @click.group(help='View positions and stats for your portfolio.')
5
+ async def portfolio():
6
+ pass
@@ -0,0 +1,152 @@
1
+ import getpass
2
+ import logging
3
+ import os
4
+ import pickle
5
+ import shutil
6
+ import sys
7
+ from configparser import ConfigParser
8
+ from datetime import date
9
+ from decimal import Decimal
10
+ from typing import Optional
11
+
12
+ import requests
13
+ from rich import print as rich_print
14
+ from tastytrade import Account, ProductionSession
15
+ from tastytrade.order import NewOrder, PlacedOrderResponse
16
+
17
+ logger = logging.getLogger(__name__)
18
+ VERSION = '0.1'
19
+ ZERO = Decimal(0)
20
+
21
+ CONTEXT_SETTINGS = {'help_option_names': ['-h', '--help']}
22
+
23
+ CUSTOM_CONFIG_PATH = '.config/ttcli/ttcli.cfg'
24
+ DEFAULT_CONFIG_PATH = 'etc/ttcli.cfg'
25
+ TOKEN_PATH = '.config/ttcli/.session'
26
+
27
+
28
+ def print_error(msg: str):
29
+ rich_print(f'[bold red]Error: {msg}[/bold red]')
30
+
31
+
32
+ def print_warning(msg: str):
33
+ rich_print(f'[light_coral]Warning: {msg}[/light_coral]')
34
+
35
+
36
+ def test_order_handle_errors(
37
+ account: Account,
38
+ session: 'RenewableSession',
39
+ order: NewOrder
40
+ ) -> Optional[PlacedOrderResponse]:
41
+ url = f'{session.base_url}/accounts/{account.account_number}/orders/dry-run'
42
+ json = order.model_dump_json(exclude_none=True, by_alias=True)
43
+
44
+ response = requests.post(url, headers=session.headers, data=json)
45
+ # modified to use our error handling
46
+ if response.status_code // 100 != 2:
47
+ content = response.json()['error']
48
+ print_error(f"{content['message']}")
49
+ errors = content.get('errors')
50
+ if errors is not None:
51
+ for error in errors:
52
+ if "code" in error:
53
+ print_error(f"{error['message']}")
54
+ else:
55
+ print_error(f"{error['reason']}")
56
+ return None
57
+ else:
58
+ data = response.json()['data']
59
+ return PlacedOrderResponse(**data)
60
+
61
+
62
+ class RenewableSession(ProductionSession):
63
+ def __init__(self):
64
+ custom_path = os.path.join(os.path.expanduser('~'), CUSTOM_CONFIG_PATH)
65
+ default_path = os.path.join(sys.prefix, DEFAULT_CONFIG_PATH)
66
+ token_path = os.path.join(os.path.expanduser('~'), TOKEN_PATH)
67
+
68
+ logged_in = False
69
+ # try to load token
70
+ if os.path.exists(token_path):
71
+ with open(token_path, 'rb') as f:
72
+ self.__dict__ = pickle.load(f)
73
+
74
+ # make sure token hasn't expired
75
+ logged_in = self.validate()
76
+
77
+ # load config
78
+ self.config = ConfigParser()
79
+ if not os.path.exists(custom_path):
80
+ # copy default config to user home dir
81
+ os.makedirs(os.path.dirname(custom_path), exist_ok=True)
82
+ shutil.copyfile(default_path, custom_path)
83
+ self.config.read(default_path)
84
+ self.config.read(custom_path)
85
+
86
+ if not logged_in:
87
+ # either the token expired or doesn't exist
88
+ username, password = self._get_credentials()
89
+ ProductionSession.__init__(self, username, password)
90
+
91
+ accounts = Account.get_accounts(self)
92
+ self.accounts = [acc for acc in accounts if not acc.is_closed]
93
+ # write session token to cache
94
+ os.makedirs(os.path.dirname(token_path), exist_ok=True)
95
+ with open(token_path, 'wb') as f:
96
+ pickle.dump(self.__dict__, f)
97
+ logger.debug('Logged in with new session, cached for next login.')
98
+ else:
99
+ logger.debug('Logged in with cached session.')
100
+
101
+ def _get_credentials(self):
102
+ username = os.getenv('TT_USERNAME')
103
+ password = os.getenv('TT_PASSWORD')
104
+ if self.config.has_section('general'):
105
+ username = username or self.config['general'].get('username')
106
+ password = password or self.config['general'].get('password')
107
+
108
+ if not username:
109
+ username = getpass.getpass('Username: ')
110
+ if not password:
111
+ password = getpass.getpass('Password: ')
112
+
113
+ return username, password
114
+
115
+ def get_account(self) -> Account:
116
+ account = self.config['general'].get('default-account', None)
117
+ if account:
118
+ try:
119
+ return next(a for a in self.accounts if a.account_number == account)
120
+ except StopIteration:
121
+ print_warning('Default account is set, but the account doesn\'t appear to exist!')
122
+
123
+ for i in range(len(self.accounts)):
124
+ if i == 0:
125
+ print(f'{i + 1}) {self.accounts[i].account_number} '
126
+ f'{self.accounts[i].nickname} (default)')
127
+ else:
128
+ print(f'{i + 1}) {self.accounts[i].account_number} {self.accounts[i].nickname}')
129
+ choice = 0
130
+ while choice not in range(1, len(self.accounts) + 1):
131
+ try:
132
+ raw = input('Please choose an account: ')
133
+ choice = int(raw)
134
+ except ValueError:
135
+ if not raw:
136
+ return self.accounts[0]
137
+ return self.accounts[choice - 1]
138
+
139
+
140
+ def is_monthly(day: date) -> bool:
141
+ return day.weekday() == 4 and 15 <= day.day <= 21
142
+
143
+
144
+ def get_confirmation(prompt: str) -> bool:
145
+ while True:
146
+ answer = input(prompt).lower()
147
+ if not answer:
148
+ return True
149
+ if answer[0] == 'y':
150
+ return True
151
+ if answer[0] == 'n':
152
+ return False