Skip to content

Commit 0c6c0b1

Browse files
authored
Move to async-only library for version>=12 (#299)
* Move to async-only library for version>=12 For a long time this library was mostly sync, with the streamers being the exception. PR #168 added async endpoints on top of the existing endpoints, which gave users more flexibility. With this change, I propose that version 12+ be async-only. There are a few reasons for this. First of all, having both sync and async versions of everything resulted in a ton of code duplication (check out how many lines are removed in this commit!). Second, both streamers are (and always have been) async, meaning that almost all users are already implementing at least *some* async code, so having the separate sync versions makes less sense. Finally, this change allows us to support Trio and clean up the streamer implementation which was hard to reason about and prone to bugs. Summary of important changes: - `httpx-ws` library replaces `websockets`, allowing for Trio support. As a consequence of this, reconnect and disconnect functionality is gone (however, this is almost certainly better handled on the user side anyways). - Python 3.10 support is dropped as we're now using `ExceptionGroup`s. This would likely have happened soon anyways as the official support lifecycle only goes through October 2026. - All previous sync endpoints are now async. All previous async endpoints have been removed (so instead of having endpoints named `Account.a_get` for async and `Account.get` for sync, we have a single endpoint named `Account.get`, which is async). - Session token refreshing is smarter: Instead of making users check and refresh themselves, they will auto-refresh before any request if they're close to expiry. Sessions also have an optional async context manager which can be used to ensure cleanup happens. - Streamers now use `httpx-ws` websockets and structured concurrency, making them much simpler and easier to reason about (which implies a lower chance of bugs in the future). We also get to use the helpful `send_json` and `receive_json` functions instead of wrapping everything in `json.dumps`/`json.loads`. - `tastytrade.dxfeed.Quote` events now have helpful `mid_price` and `micro_price` properties calculated from its bid/ask prices. - `tastytrade.order.OrderAction` has a `multiplier` property which is `-1` when it's a "Sell" leg and `1` when it's a "Buy" leg. - Test suite now runs on both asyncio and Trio. * update docs * bump min pydantic version for 3.14 support
1 parent 2d951dd commit 0c6c0b1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1104
-3732
lines changed

.github/pull_request_template.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ Fixes ...
55

66
## Pre-merge checklist
77
- [ ] Code formatted correctly (check with `make lint`)
8-
- [ ] Code implemented for both sync and async
98
- [ ] Passing tests locally (check with `make test`, make sure you have `TT_REFRESH`, `TT_SECRET`, and `TT_ACCOUNT` environment variables set)
109
- [ ] New tests added (if applicable)
10+
- [ ] Docs updated (if applicable)
1111

1212
Please note that, in order to pass the tests, you'll need to set up your Tastytrade credentials as repository secrets on your local fork. Read more at CONTRIBUTING.md.

.github/workflows/python-app.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ jobs:
1313
max-parallel: 1
1414
matrix:
1515
python-version:
16-
- "3.10"
1716
- "3.11"
1817
- "3.12"
1918
- "3.13"
@@ -39,7 +38,7 @@ jobs:
3938
uv run mypy tastytrade/
4039
- name: Test with pytest
4140
run: |
42-
uv run pytest --cov=tastytrade --cov-report=term-missing tests/ --cov-fail-under=95
41+
uv run pytest --cov=tastytrade --cov-report=term-missing tests/ --cov-fail-under=95 -v
4342
env:
4443
TT_REFRESH_SANDBOX: ${{ secrets.TT_REFRESH_SANDBOX }}
4544
TT_SECRET_SANDBOX: ${{ secrets.TT_SECRET_SANDBOX }}

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.10
1+
3.11

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ lint:
1111
uv run mypy tastytrade/
1212

1313
test:
14-
uv run pytest --cov=tastytrade --cov-report=term-missing tests/ --cov-fail-under=95
14+
uv run pytest --cov=tastytrade --cov-report=term-missing tests/ --cov-fail-under=95 -v
1515

1616
docs:
1717
uv run -m sphinx -T -b html -d docs/_build/doctrees -D language=en docs/ docs/_build/

README.md

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ A simple, reverse-engineered SDK for Tastytrade built on their (now mostly publi
1010
## Features
1111

1212
- Up to 10x less code than using the API directly
13-
- Sync/async functions for all endpoints
14-
- Powerful websocket implementation for account alerts and data streaming, with support for auto-reconnection and reconnection callbacks
13+
- Powerful websocket implementation for account alerts and data streaming
1514
- 100% typed, with Pydantic models for all JSON responses from the API
1615
- 95%+ unit test coverage
1716
- Comprehensive documentation
@@ -65,27 +64,15 @@ Note that this is asynchronous code, so you can't run it as is unless you're usi
6564
```python
6665
from tastytrade import Account
6766

68-
account = Account.get(session)[0]
69-
positions = account.get_positions(session)
67+
account = (await Account.get(session))[0]
68+
positions = await account.get_positions(session)
7069
print(positions[0])
7170
```
7271

7372
```python
7473
>>> CurrentPosition(account_number='5WX01234', symbol='IAU', instrument_type=<InstrumentType.EQUITY: 'Equity'>, underlying_symbol='IAU', quantity=Decimal('20'), quantity_direction='Long', close_price=Decimal('37.09'), average_open_price=Decimal('37.51'), average_yearly_market_close_price=Decimal('37.51'), average_daily_market_close_price=Decimal('37.51'), multiplier=1, cost_effect=<PriceEffect.CREDIT: 'Credit'>, is_suppressed=False, is_frozen=False, realized_day_gain=Decimal('7.888'), realized_day_gain_date=datetime.date(2023, 5, 19), realized_today=Decimal('-0.512'), realized_today_date=datetime.date(2023, 5, 19), created_at=datetime.datetime(2023, 3, 31, 14, 38, 32, 58000, tzinfo=datetime.timezone.utc), updated_at=datetime.datetime(2023, 5, 19, 16, 56, 51, 920000, tzinfo=datetime.timezone.utc), mark=None, mark_price=None, restricted_quantity=Decimal('0'), expires_at=None, fixing_price=None, deliverable_type=None)
7574
```
7675

77-
## Sync/async wrappers
78-
79-
The code from above can be rewritten asynchronously:
80-
81-
```python
82-
from tastytrade import Account
83-
84-
account = (await Account.a_get(session))[0]
85-
positions = await account.a_get_positions(session)
86-
print(positions[0])
87-
```
88-
8976
## Placing an order
9077

9178
```python
@@ -94,8 +81,8 @@ from tastytrade import Account
9481
from tastytrade.instruments import Equity
9582
from tastytrade.order import NewOrder, OrderAction, OrderTimeInForce, OrderType
9683

97-
account = Account.get(session, '5WX01234')
98-
symbol = Equity.get(session, 'USO')
84+
account = await Account.get(session, '5WX01234')
85+
symbol = await Equity.get(session, 'USO')
9986
leg = symbol.build_leg(Decimal('5'), OrderAction.BUY_TO_OPEN) # buy to open 5 shares
10087

10188
order = NewOrder(
@@ -104,7 +91,7 @@ order = NewOrder(
10491
legs=[leg], # you can have multiple legs in an order
10592
price=Decimal('-10') # limit price, $10/share debit for a total value of $50
10693
)
107-
response = account.place_order(session, order, dry_run=True) # a test order
94+
response = await account.place_order(session, order, dry_run=True) # a test order
10895
print(response)
10996
```
11097

@@ -120,7 +107,7 @@ from tastytrade.dxfeed import Greeks
120107
from tastytrade.instruments import get_option_chain
121108
from tastytrade.utils import get_tasty_monthly
122109

123-
chain = get_option_chain(session, 'SPLG')
110+
chain = await get_option_chain(session, 'SPLG')
124111
exp = get_tasty_monthly() # 45 DTE expiration!
125112
subs_list = [chain[exp][0].streamer_symbol]
126113

docs/account-streamer.rst

Lines changed: 17 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@ Here's an example of setting up an account streamer to continuously wait for eve
1414
from tastytrade import Account, AlertStreamer, Watchlist
1515
1616
async with AlertStreamer(session) as streamer:
17-
accounts = Account.get(session)
18-
19-
# updates to balances, orders, and positions
20-
await streamer.subscribe_accounts(accounts)
2117
# changes in public watchlists
2218
await streamer.subscribe_public_watchlists()
2319
# quote alerts configured by the user
@@ -33,50 +29,31 @@ Probably the most important information the account streamer handles is order fi
3329
from tastytrade.order import PlacedOrder
3430
3531
async with AlertStreamer(session) as streamer:
36-
accounts = Account.get(session)
32+
# updates to balances, orders, and positions
33+
accounts = await Account.get(session)
3734
await streamer.subscribe_accounts(accounts)
3835
3936
async for order in streamer.listen(PlacedOrder):
4037
print(order)
4138
42-
Disconnect callback
43-
-------------------
39+
Auto-retries
40+
------------
4441

45-
The disconnect callback can be used to run arbitrary code when the websocket connection has been disconnected.
46-
This is useful for notification purposes in your application when you need high availability.
47-
The callback function should look something like this:
42+
Often, account streamer connections should be long-lived and persistent. While the SDK doesn't provide this functionality itself, it's pretty trivial to build with just a few lines of code:
4843

4944
.. code-block:: python
5045
51-
async def disconnect_callback(streamer: AlertStreamer):
52-
print("Disconnected!")
53-
54-
The requirements are that the first parameter be the `AlertStreamer` instance, and the function should be asynchronous.
55-
This callback can then be used when creating the streamer:
56-
57-
.. code-block:: python
58-
59-
async with AlertStreamer(session, disconnect_fn=disconnect_callback) as streamer:
60-
# ...
61-
62-
Retry callback
63-
--------------
64-
65-
The account streamer has a special "callback" function which can be used to execute arbitrary code whenever the websocket reconnects. This is useful for re-subscribing to whatever alerts you wanted to subscribe to initially (in fact, you can probably use the same function/code you use when initializing the connection).
66-
The callback function should look something like this:
67-
68-
.. code-block:: python
69-
70-
async def reconnect_callback(streamer: AlertStreamer, arg1, arg2):
71-
await streamer.subscribe_quote_alerts()
72-
73-
The requirements are that the first parameter be the `AlertStreamer` instance, and the function should be asynchronous. Other than that, you have the flexibility to decide what arguments you want to use.
74-
This callback can then be used when creating the streamer:
75-
76-
.. code-block:: python
46+
from anyio import sleep
47+
from httpx_ws import HTTPXWSException
7748
78-
async with AlertStreamer(session, reconnect_fn=reconnect_callback, reconnect_args=(arg1, arg2)) as streamer:
79-
# ...
49+
tries, max_tries = 0, 3
50+
while (tries := tries + 1) <= max_tries:
51+
try:
52+
async with AlertStreamer(session) as streamer:
53+
print("Yay! Connected!")
54+
...
55+
except* HTTPXWSException:
56+
print("Oh no! Disconnected!")
57+
await sleep(tries ** 2)
8058
81-
The reconnection uses `websockets`' exponential backoff algorithm, which can be configured through environment variables `here <https://websockets.readthedocs.io/en/14.1/reference/variables.html>`_.
82-
The difference between the disconnect and reconnect callbacks is that the disconnect will be called immediately when the connection is broken, whereas the reconnect callback will only be called once the connection is re-established.
59+
Now we have persistence, exponential backoff, and disconnect/reconnect hooks--easy!

docs/accounts.rst

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,19 @@ The easiest way to get an account is to grab all accounts associated with a spec
88
.. code-block:: python
99
1010
from tastytrade import Account
11-
accounts = Account.get(session)
11+
accounts = await Account.get(session)
1212
1313
You can also get a specific account by its unique ID:
1414

1515
.. code-block:: python
1616
17-
account = Account.get(session, '5WX01234')
17+
account = await Account.get(session, '5WX01234')
1818
1919
The ``get_balances`` function can be used to obtain information about the current buying power and cash balance:
2020

2121
.. code-block:: python
2222
23-
balance = account.get_balances(session)
23+
balance = await account.get_balances(session)
2424
print(balance)
2525
2626
>>> AccountBalance(account_number='5WX01234', cash_balance=Decimal('87.055'), long_equity_value=Decimal('4046.05'), short_equity_value=Decimal('0.0'), long_derivative_value=Decimal('0.0'), short_derivative_value=Decimal('0.0'), long_futures_value=Decimal('0.0'), short_futures_value=Decimal('0.0'), long_futures_derivative_value=Decimal('0.0'), short_futures_derivative_value=Decimal('0.0'), long_margineable_value=Decimal('0.0'), short_margineable_value=Decimal('0.0'), margin_equity=Decimal('4133.105'), equity_buying_power=Decimal('87.055'), derivative_buying_power=Decimal('87.055'), day_trading_buying_power=Decimal('0.0'), futures_margin_requirement=Decimal('0.0'), available_trading_funds=Decimal('0.0'), maintenance_requirement=Decimal('4048.85'), maintenance_call_value=Decimal('0.0'), reg_t_call_value=Decimal('0.0'), day_trading_call_value=Decimal('0.0'), day_equity_call_value=Decimal('0.0'), net_liquidating_value=Decimal('4133.105'), cash_available_to_withdraw=Decimal('87.06'), day_trade_excess=Decimal('87.06'), pending_cash=Decimal('0.0'), pending_cash_effect=<PriceEffect.NONE: 'None'>, long_cryptocurrency_value=Decimal('0.0'), short_cryptocurrency_value=Decimal('0.0'), cryptocurrency_margin_requirement=Decimal('0.0'), unsettled_cryptocurrency_fiat_amount=Decimal('0.0'), unsettled_cryptocurrency_fiat_effect=<PriceEffect.NONE: 'None'>, closed_loop_available_balance=Decimal('87.06'), equity_offering_margin_requirement=Decimal('0.0'), long_bond_value=Decimal('0.0'), bond_margin_requirement=Decimal('0.0'), snapshot_date=datetime.date(2023, 11, 28), reg_t_margin_requirement=Decimal('4048.85'), futures_overnight_margin_requirement=Decimal('0.0'), futures_intraday_margin_requirement=Decimal('0.0'), maintenance_excess=Decimal('87.055'), pending_margin_interest=Decimal('0.0'), effective_cryptocurrency_buying_power=Decimal('87.055'), updated_at=datetime.datetime(2023, 11, 28, 20, 54, 33, 556000, tzinfo=datetime.timezone.utc), apex_starting_day_margin_equity=None, buying_power_adjustment=None, buying_power_adjustment_effect=None, time_of_day=None)
@@ -29,7 +29,7 @@ To obtain information about current positions:
2929

3030
.. code-block:: python
3131
32-
positions = account.get_positions(session)
32+
positions = await account.get_positions(session)
3333
print(positions[0])
3434
3535
>>> CurrentPosition(account_number='5WX01234', symbol='BRK/B', instrument_type=<InstrumentType.EQUITY: 'Equity'>, underlying_symbol='BRK/B', quantity=Decimal('10'), quantity_direction='Long', close_price=Decimal('361.34'), average_open_price=Decimal('339.63'), multiplier=1, cost_effect='Credit', is_suppressed=False, is_frozen=False, realized_day_gain=Decimal('18.5'), realized_today=Decimal('279.15'), created_at=datetime.datetime(2023, 3, 31, 14, 35, 40, 138000, tzinfo=datetime.timezone.utc), updated_at=datetime.datetime(2023, 8, 10, 15, 42, 7, 482000, tzinfo=datetime.timezone.utc), mark=None, mark_price=None, restricted_quantity=Decimal('0'), expires_at=None, fixing_price=None, deliverable_type=None, average_yearly_market_close_price=Decimal('339.63'), average_daily_market_close_price=Decimal('361.34'), realized_day_gain_effect=<PriceEffect.CREDIT: 'Credit'>, realized_day_gain_date=datetime.date(2023, 8, 10), realized_today_effect=<PriceEffect.CREDIT: 'Credit'>, realized_today_date=datetime.date(2023, 8, 10))
@@ -38,7 +38,7 @@ To fetch a list of past transactions:
3838

3939
.. code-block:: python
4040
41-
history = account.get_history(session, start_date=date(2024, 1, 1))
41+
history = await account.get_history(session, start_date=date(2024, 1, 1))
4242
print(history[-1])
4343
4444
>>> Transaction(id=280070508, account_number='5WX01234', transaction_type='Trade', transaction_sub_type='Sell to Close', description='Sold 10 BRK/B @ 384.04', executed_at=datetime.datetime(2024, 1, 26, 15, 51, 53, 685000, tzinfo=datetime.timezone.utc), transaction_date=datetime.date(2024, 1, 26), value=Decimal('3840.4'), value_effect=<PriceEffect.CREDIT: 'Credit'>, net_value=Decimal('3840.35'), net_value_effect=<PriceEffect.CREDIT: 'Credit'>, is_estimated_fee=True, symbol='BRK/B', instrument_type=<InstrumentType.EQUITY: 'Equity'>, underlying_symbol='BRK/B', action='Sell to Close', quantity=Decimal('10.0'), price=Decimal('384.04'), regulatory_fees=Decimal('0.042'), regulatory_fees_effect=<PriceEffect.DEBIT: 'Debit'>, clearing_fees=Decimal('0.008'), clearing_fees_effect=<PriceEffect.DEBIT: 'Debit'>, commission=Decimal('0.0'), commission_effect=<PriceEffect.NONE: 'None'>, proprietary_index_option_fees=Decimal('0.0'), proprietary_index_option_fees_effect=<PriceEffect.NONE: 'None'>, ext_exchange_order_number='12271026815307', ext_global_order_number=2857, ext_group_id='0', ext_group_fill_id='0', ext_exec_id='0', exec_id='123_40126000126350300000', exchange='JNS', order_id=305250635, exchange_affiliation_identifier='', leg_count=1, destination_venue='JANE_STREET_EQUITIES_A', other_charge=None, other_charge_effect=None, other_charge_description=None, reverses_id=None, cost_basis_reconciliation_date=None, lots=None, agency_price=None, principal_price=None)
@@ -48,7 +48,7 @@ We can also view portfolio P/L over time (and even plot it!):
4848
.. code-block:: python
4949
5050
import matplotlib.pyplot as plt
51-
nl = account.get_net_liquidating_value_history(session, time_back='1m') # past 1 month
51+
nl = await account.get_net_liquidating_value_history(session, time_back='1m') # past 1 month
5252
plt.plot([n.time for n in nl], [n.close for n in nl])
5353
plt.show()
5454

0 commit comments

Comments
 (0)