tastytrade 10.2.2__tar.gz → 10.3.0__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.
- {tastytrade-10.2.2 → tastytrade-10.3.0}/.github/workflows/python-app.yml +2 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/PKG-INFO +1 -1
- tastytrade-10.3.0/docs/sessions.rst +73 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/pyproject.toml +3 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/__init__.py +3 -2
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/account.py +9 -8
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/backtest.py +2 -2
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/market_data.py +1 -0
- tastytrade-10.3.0/tastytrade/oauth.py +129 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/session.py +62 -62
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/streamer.py +38 -36
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/utils.py +25 -38
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_backtest.py +4 -1
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_instruments.py +12 -2
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_session.py +24 -1
- tastytrade-10.2.2/docs/sessions.rst +0 -32
- {tastytrade-10.2.2 → tastytrade-10.3.0}/.github/CONTRIBUTING.md +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/.github/FUNDING.yml +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/.github/pull_request_template.md +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/.github/workflows/python-publish-test.yml +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/.github/workflows/python-publish.yml +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/.gitignore +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/.python-version +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/.readthedocs.yaml +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/LICENSE +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/Makefile +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/README.md +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/Makefile +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/account-streamer.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/accounts.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/account.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/backtesting.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/dxfeed.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/instruments.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/market-data.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/market-sessions.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/metrics.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/order.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/search.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/session.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/streamer.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/utils.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/watchlists.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/backtest.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/conf.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/data-streamer.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/img/netliq.png +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/index.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/installation.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/instruments.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/make.bat +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/market-data.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/market-sessions.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/orders.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/sync-async.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/watchlists.rst +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/__init__.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/candle.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/event.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/greeks.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/profile.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/quote.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/summary.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/theoprice.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/timeandsale.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/trade.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/underlying.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/instruments.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/market_sessions.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/metrics.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/order.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/py.typed +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/search.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/watchlists.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/__init__.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/conftest.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_account.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_dxfeed.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_market_data.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_market_sessions.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_metrics.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_search.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_streamer.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_utils.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_watchlists.py +0 -0
- {tastytrade-10.2.2 → tastytrade-10.3.0}/uv.lock +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Sessions
|
|
2
|
+
========
|
|
3
|
+
|
|
4
|
+
Creating a session
|
|
5
|
+
------------------
|
|
6
|
+
|
|
7
|
+
A session object is required to authenticate your requests to the Tastytrade API.
|
|
8
|
+
To create a production (real) session using your normal login:
|
|
9
|
+
|
|
10
|
+
.. code-block:: python
|
|
11
|
+
|
|
12
|
+
from tastytrade import Session
|
|
13
|
+
session = Session('username', 'password')
|
|
14
|
+
|
|
15
|
+
A certification (test) account can be created `here <https://developer.tastytrade.com/sandbox/>`_, then used to create a session.
|
|
16
|
+
|
|
17
|
+
.. code-block:: python
|
|
18
|
+
|
|
19
|
+
from tastytrade import Session
|
|
20
|
+
session = Session('username', 'password', is_test=True)
|
|
21
|
+
|
|
22
|
+
You can make a session persistent by generating a remember token, which is valid for 24 hours:
|
|
23
|
+
|
|
24
|
+
.. code-block:: python
|
|
25
|
+
|
|
26
|
+
session = Session('username', 'password', remember_me=True)
|
|
27
|
+
remember_token = session.remember_token
|
|
28
|
+
# remember token replaces the password for the next login
|
|
29
|
+
new_session = Session('username', remember_token=remember_token)
|
|
30
|
+
|
|
31
|
+
.. note::
|
|
32
|
+
If you used a certification (test) account to create the session associated with the `remember_token`, you must set `is_test=True` when creating subsequent sessions.
|
|
33
|
+
|
|
34
|
+
OAuth sessions
|
|
35
|
+
--------------
|
|
36
|
+
|
|
37
|
+
Tastytrade has recently added support for OAuth logins, which allow you to connect an application for the purposes of managing trades on your behalf. Apart from allowing you to connect to 3rd-party apps (or build your own), you can also build a private OAuth application, which provides better security compared to username/password authentication since you don't have to expose your login information.
|
|
38
|
+
|
|
39
|
+
To get started, create a new OAuth application `here <https://my.tastytrade.com/app.html#/manage/api-access/oauth-applications>`_. You'll need to check all the scopes and save the client ID and client secret. Then, run this code:
|
|
40
|
+
|
|
41
|
+
.. code-block:: python
|
|
42
|
+
|
|
43
|
+
from tastytrade.oauth import login
|
|
44
|
+
|
|
45
|
+
login()
|
|
46
|
+
|
|
47
|
+
This will open up a web interface in your browser where you'll be prompted to paste your client ID and client secret. These credentials will then be used to connect your application to Tastytrade. After following the steps in your browser, you should see your refresh token in the browser and in the console, which you should save.
|
|
48
|
+
|
|
49
|
+
At this point, OAuth is now setup correctly! Doing the above once is sufficient for **indefinite usage** of ``OAuthSession`` for authentication to the API, since refresh tokens never expire. From now on you can simply authenticate like so:
|
|
50
|
+
|
|
51
|
+
.. code-block:: python
|
|
52
|
+
|
|
53
|
+
from tastytrade import OAuthSession
|
|
54
|
+
|
|
55
|
+
session = OAuthSession('my-client-secret', 'my-refresh-token')
|
|
56
|
+
|
|
57
|
+
These session objects can be used almost anywhere you can use a normal session:
|
|
58
|
+
|
|
59
|
+
.. code-block:: python
|
|
60
|
+
|
|
61
|
+
from tastytrade import Account
|
|
62
|
+
|
|
63
|
+
accounts = Account.get(session)
|
|
64
|
+
|
|
65
|
+
Note that OAuth sessions make API requests using a special session token, which has a duration of only 15 minutes. However, since the refresh tokens last forever, you can call ``OAuthSession.refresh()`` to refresh the session token whenever needed. The session object will keep track of session expiration time for you to make it easier to know when to refresh:
|
|
66
|
+
|
|
67
|
+
.. code-block:: python
|
|
68
|
+
|
|
69
|
+
from tastytrade.utils import now_in_new_york
|
|
70
|
+
|
|
71
|
+
if now_in_new_york() > session.session_expiration:
|
|
72
|
+
session.refresh()
|
|
73
|
+
print(Account.get(session))
|
|
@@ -4,7 +4,7 @@ API_URL = "https://api.tastyworks.com"
|
|
|
4
4
|
BACKTEST_URL = "https://backtester.vast.tastyworks.com"
|
|
5
5
|
CERT_URL = "https://api.cert.tastyworks.com"
|
|
6
6
|
VAST_URL = "https://vast.tastyworks.com"
|
|
7
|
-
VERSION = "10.
|
|
7
|
+
VERSION = "10.3.0"
|
|
8
8
|
|
|
9
9
|
__version__ = VERSION
|
|
10
10
|
version_str: str = f"tastyware/tastytrade:v{VERSION}"
|
|
@@ -15,12 +15,13 @@ logger.setLevel(logging.DEBUG)
|
|
|
15
15
|
# ruff: noqa: E402
|
|
16
16
|
|
|
17
17
|
from .account import Account
|
|
18
|
-
from .session import Session
|
|
18
|
+
from .session import OAuthSession, Session
|
|
19
19
|
from .streamer import AlertStreamer, DXLinkStreamer
|
|
20
20
|
|
|
21
21
|
__all__ = [
|
|
22
22
|
"Account",
|
|
23
23
|
"AlertStreamer",
|
|
24
24
|
"DXLinkStreamer",
|
|
25
|
+
"OAuthSession",
|
|
25
26
|
"Session",
|
|
26
27
|
]
|
|
@@ -96,8 +96,10 @@ class AccountBalance(TastytradeData):
|
|
|
96
96
|
if isinstance(data, dict):
|
|
97
97
|
data = cast(dict[str, Any], data)
|
|
98
98
|
key = "unsettled-cryptocurrency-fiat-amount"
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
if (
|
|
100
|
+
data.get("unsettled-cryptocurrency-fiat-effect")
|
|
101
|
+
== PriceEffect.DEBIT.value
|
|
102
|
+
):
|
|
101
103
|
data[key] = -abs(Decimal(data[key]))
|
|
102
104
|
return set_sign_for(data, ["pending_cash", "buying_power_adjustment"])
|
|
103
105
|
|
|
@@ -152,8 +154,7 @@ class AccountBalanceSnapshot(TastytradeData):
|
|
|
152
154
|
if isinstance(data, dict):
|
|
153
155
|
data = cast(dict[str, Any], data)
|
|
154
156
|
key = "unsettled-cryptocurrency-fiat-amount"
|
|
155
|
-
|
|
156
|
-
if effect == PriceEffect.DEBIT:
|
|
157
|
+
if data.get("unsettled-cryptocurrency-fiat-effect") == PriceEffect.DEBIT:
|
|
157
158
|
data[key] = -abs(Decimal(data[key]))
|
|
158
159
|
return set_sign_for(data, ["pending_cash"])
|
|
159
160
|
|
|
@@ -816,7 +817,7 @@ class Account(TastytradeData):
|
|
|
816
817
|
session: Session,
|
|
817
818
|
per_page: int = 250,
|
|
818
819
|
page_offset: Optional[int] = None,
|
|
819
|
-
sort:
|
|
820
|
+
sort: Literal["Asc", "Desc"] = "Desc",
|
|
820
821
|
type: Optional[str] = None,
|
|
821
822
|
types: Optional[list[str]] = None,
|
|
822
823
|
sub_types: Optional[list[str]] = None,
|
|
@@ -911,7 +912,7 @@ class Account(TastytradeData):
|
|
|
911
912
|
session: Session,
|
|
912
913
|
per_page: int = 250,
|
|
913
914
|
page_offset: Optional[int] = None,
|
|
914
|
-
sort:
|
|
915
|
+
sort: Literal["Asc", "Desc"] = "Desc",
|
|
915
916
|
type: Optional[str] = None,
|
|
916
917
|
types: Optional[list[str]] = None,
|
|
917
918
|
sub_types: Optional[list[str]] = None,
|
|
@@ -1334,7 +1335,7 @@ class Account(TastytradeData):
|
|
|
1334
1335
|
statuses: Optional[list[OrderStatus]] = None,
|
|
1335
1336
|
futures_symbol: Optional[str] = None,
|
|
1336
1337
|
underlying_instrument_type: Optional[InstrumentType] = None,
|
|
1337
|
-
sort: Optional[
|
|
1338
|
+
sort: Optional[Literal["Asc", "Desc"]] = None,
|
|
1338
1339
|
start_at: Optional[datetime] = None,
|
|
1339
1340
|
end_at: Optional[datetime] = None,
|
|
1340
1341
|
) -> list[PlacedOrder]:
|
|
@@ -1415,7 +1416,7 @@ class Account(TastytradeData):
|
|
|
1415
1416
|
statuses: Optional[list[OrderStatus]] = None,
|
|
1416
1417
|
futures_symbol: Optional[str] = None,
|
|
1417
1418
|
underlying_instrument_type: Optional[InstrumentType] = None,
|
|
1418
|
-
sort: Optional[
|
|
1419
|
+
sort: Optional[Literal["Asc", "Desc"]] = None,
|
|
1419
1420
|
start_at: Optional[datetime] = None,
|
|
1420
1421
|
end_at: Optional[datetime] = None,
|
|
1421
1422
|
) -> list[PlacedOrder]:
|
|
@@ -57,7 +57,7 @@ class BacktestLeg(BacktestData):
|
|
|
57
57
|
|
|
58
58
|
days_until_expiration: int = 45
|
|
59
59
|
delta: int = 15
|
|
60
|
-
direction: Literal["
|
|
60
|
+
direction: Literal["long", "short"] = "short"
|
|
61
61
|
quantity: int = 1
|
|
62
62
|
side: Literal["call", "put"] = "call"
|
|
63
63
|
|
|
@@ -159,7 +159,7 @@ class BacktestResponse(Backtest):
|
|
|
159
159
|
|
|
160
160
|
created_at: datetime
|
|
161
161
|
id: str
|
|
162
|
-
results: BacktestResults
|
|
162
|
+
results: Optional[BacktestResults] = None
|
|
163
163
|
eta: Optional[int] = None
|
|
164
164
|
progress: Optional[Decimal] = None
|
|
165
165
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import webbrowser
|
|
3
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
4
|
+
from urllib.parse import parse_qs
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
PORT = 8000
|
|
9
|
+
REDIRECT_URI = f"http://localhost:{PORT}"
|
|
10
|
+
SCOPES = ["read", "trade", "openid"]
|
|
11
|
+
|
|
12
|
+
authorize_url = "https://my.tastytrade.com/auth.html"
|
|
13
|
+
token_url = "https://api.tastyworks.com/oauth/token"
|
|
14
|
+
client_id = ""
|
|
15
|
+
client_secret = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
root_page = """
|
|
19
|
+
<!DOCTYPE html>
|
|
20
|
+
<html lang="en-us">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8">
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
24
|
+
<title>OAuth Setup</title>
|
|
25
|
+
<!-- Favicon -->
|
|
26
|
+
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
|
27
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
|
28
|
+
rel="stylesheet"
|
|
29
|
+
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
|
|
30
|
+
crossorigin="anonymous">
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<div class="container position-absolute top-50 start-50 translate-middle"
|
|
34
|
+
style="width: 400px">
|
|
35
|
+
<form method="POST">
|
|
36
|
+
<div class="row mb-3">
|
|
37
|
+
<input type="text"
|
|
38
|
+
required
|
|
39
|
+
placeholder="Client ID"
|
|
40
|
+
name="client_id"
|
|
41
|
+
class="form-control">
|
|
42
|
+
</div>
|
|
43
|
+
<div class="row mb-3">
|
|
44
|
+
<input type="password"
|
|
45
|
+
required
|
|
46
|
+
placeholder="Client Secret"
|
|
47
|
+
name="client_secret"
|
|
48
|
+
class="form-control">
|
|
49
|
+
</div>
|
|
50
|
+
<div class="row mb-3">
|
|
51
|
+
<button type="submit" class="btn btn-success">Connect</button>
|
|
52
|
+
</div>
|
|
53
|
+
</form>
|
|
54
|
+
</div>
|
|
55
|
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
|
56
|
+
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
|
57
|
+
crossorigin="anonymous"></script>
|
|
58
|
+
</body>
|
|
59
|
+
</html>
|
|
60
|
+
""".encode()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RequestHandler(BaseHTTPRequestHandler):
|
|
64
|
+
def do_POST(self) -> None:
|
|
65
|
+
global client_id, client_secret
|
|
66
|
+
content_length = int(self.headers["Content-Length"])
|
|
67
|
+
raw = self.rfile.read(content_length)
|
|
68
|
+
data = parse_qs(raw.decode("utf-8"))
|
|
69
|
+
client_id = data["client_id"][0]
|
|
70
|
+
client_secret = data["client_secret"][0]
|
|
71
|
+
|
|
72
|
+
# Redirect to login page using API key submitted by user
|
|
73
|
+
self.send_response(302)
|
|
74
|
+
query_string = "&".join(
|
|
75
|
+
[
|
|
76
|
+
"response_type=code",
|
|
77
|
+
f"redirect_uri={REDIRECT_URI}",
|
|
78
|
+
f"client_id={data['client_id'][0]}",
|
|
79
|
+
f"scope={' '.join(SCOPES)}",
|
|
80
|
+
]
|
|
81
|
+
)
|
|
82
|
+
url = f"{authorize_url}?{query_string}"
|
|
83
|
+
self.send_header("Location", url)
|
|
84
|
+
self.end_headers()
|
|
85
|
+
|
|
86
|
+
def do_GET(self) -> None:
|
|
87
|
+
global client_id, client_secret
|
|
88
|
+
# Serve root page with sign in link
|
|
89
|
+
if self.path == "/":
|
|
90
|
+
self.send_response(200)
|
|
91
|
+
self.send_header("Content-type", "text/html; charset=utf-8")
|
|
92
|
+
self.end_headers()
|
|
93
|
+
self.wfile.write(root_page)
|
|
94
|
+
else:
|
|
95
|
+
# Check if query path contains case insensitive "code="
|
|
96
|
+
code_match = re.search(r"code=(.+)", self.path, re.I)
|
|
97
|
+
if code_match and client_id and client_secret:
|
|
98
|
+
user_auth_code = code_match[1]
|
|
99
|
+
post_data = {
|
|
100
|
+
"grant_type": "authorization_code",
|
|
101
|
+
"client_id": client_id,
|
|
102
|
+
"client_secret": client_secret,
|
|
103
|
+
"redirect_uri": REDIRECT_URI,
|
|
104
|
+
"code": user_auth_code,
|
|
105
|
+
}
|
|
106
|
+
response = httpx.post(token_url, data=post_data)
|
|
107
|
+
token_access = response.json()
|
|
108
|
+
refresh_token: str = token_access["refresh_token"]
|
|
109
|
+
print(refresh_token)
|
|
110
|
+
|
|
111
|
+
self.send_response(200)
|
|
112
|
+
self.send_header("Content-type", "text/html; charset=utf-8")
|
|
113
|
+
self.end_headers()
|
|
114
|
+
self.wfile.write(refresh_token.encode())
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def login(is_test: bool = False) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Starts a local HTTP server and opens the browser to OAuth login.
|
|
120
|
+
Designed for one-time use to get a refresh token.
|
|
121
|
+
"""
|
|
122
|
+
global authorize_url, token_url
|
|
123
|
+
if is_test:
|
|
124
|
+
authorize_url = "https://cert-my.staging-tasty.works/auth.html"
|
|
125
|
+
token_url = "https://api.cert.tastyworks.com/oauth/token"
|
|
126
|
+
httpd = HTTPServer(("", PORT), RequestHandler)
|
|
127
|
+
print(f"Opening url: {REDIRECT_URI}")
|
|
128
|
+
webbrowser.open(REDIRECT_URI)
|
|
129
|
+
httpd.serve_forever()
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
import
|
|
5
|
-
from datetime import date, datetime
|
|
4
|
+
from datetime import date, datetime, timedelta
|
|
6
5
|
from types import TracebackType
|
|
7
6
|
from typing import Any, Optional, Union
|
|
8
7
|
|
|
@@ -11,6 +10,7 @@ from typing_extensions import Self
|
|
|
11
10
|
|
|
12
11
|
from tastytrade import API_URL, CERT_URL, logger
|
|
13
12
|
from tastytrade.utils import (
|
|
13
|
+
TZ,
|
|
14
14
|
TastytradeData,
|
|
15
15
|
TastytradeError,
|
|
16
16
|
validate_and_parse,
|
|
@@ -300,7 +300,7 @@ class Session:
|
|
|
300
300
|
remember_token: Optional[str] = None,
|
|
301
301
|
is_test: bool = False,
|
|
302
302
|
two_factor_authentication: Optional[str] = None,
|
|
303
|
-
dxfeed_tos_compliant: bool =
|
|
303
|
+
dxfeed_tos_compliant: bool = True,
|
|
304
304
|
proxy: Optional[str] = None,
|
|
305
305
|
):
|
|
306
306
|
body = {"login": login, "remember-me": remember_me}
|
|
@@ -359,15 +359,13 @@ class Session:
|
|
|
359
359
|
else "/quote-streamer-tokens"
|
|
360
360
|
)
|
|
361
361
|
data = self._get(url)
|
|
362
|
-
|
|
363
362
|
#: Auth token for dxfeed websocket
|
|
364
363
|
self.streamer_token = data["token"]
|
|
365
364
|
#: URL for dxfeed websocket
|
|
366
365
|
self.dxlink_url = data["dxlink-url"]
|
|
367
366
|
#: expiration for streamer token
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
datetime.fromisoformat(exp.replace("Z", "+00:00")) if exp else None
|
|
367
|
+
self.streamer_expiration = datetime.fromisoformat(
|
|
368
|
+
data["expires-at"].replace("Z", "+00:00")
|
|
371
369
|
)
|
|
372
370
|
|
|
373
371
|
def __enter__(self) -> Session:
|
|
@@ -486,13 +484,10 @@ class Session:
|
|
|
486
484
|
attrs = self.__dict__.copy()
|
|
487
485
|
del attrs["async_client"]
|
|
488
486
|
del attrs["sync_client"]
|
|
489
|
-
|
|
487
|
+
if "user" in attrs:
|
|
488
|
+
attrs["user"] = attrs["user"].model_dump()
|
|
490
489
|
attrs["session_expiration"] = self.session_expiration.strftime(_fmt)
|
|
491
|
-
attrs["streamer_expiration"] = (
|
|
492
|
-
self.streamer_expiration.strftime(_fmt)
|
|
493
|
-
if self.streamer_expiration
|
|
494
|
-
else None
|
|
495
|
-
)
|
|
490
|
+
attrs["streamer_expiration"] = self.streamer_expiration.strftime(_fmt)
|
|
496
491
|
return json.dumps(attrs)
|
|
497
492
|
|
|
498
493
|
@classmethod
|
|
@@ -501,20 +496,24 @@ class Session:
|
|
|
501
496
|
Create a new Session object from a serialized string.
|
|
502
497
|
"""
|
|
503
498
|
deserialized = json.loads(serialized)
|
|
504
|
-
|
|
499
|
+
if "user" in deserialized:
|
|
500
|
+
deserialized["user"] = User(**deserialized["user"])
|
|
505
501
|
self = cls.__new__(cls)
|
|
506
502
|
self.__dict__ = deserialized
|
|
507
503
|
base_url = CERT_URL if self.is_test else API_URL
|
|
508
504
|
headers = {
|
|
509
505
|
"Accept": "application/json",
|
|
510
506
|
"Content-Type": "application/json",
|
|
511
|
-
"Authorization": self.session_token
|
|
507
|
+
"Authorization": self.session_token
|
|
508
|
+
if "user" in deserialized
|
|
509
|
+
else f"Bearer {self.session_token}",
|
|
512
510
|
}
|
|
513
511
|
self.session_expiration = datetime.strptime(
|
|
514
512
|
deserialized["session_expiration"], _fmt
|
|
515
513
|
)
|
|
516
|
-
|
|
517
|
-
|
|
514
|
+
self.streamer_expiration = datetime.strptime(
|
|
515
|
+
deserialized["streamer_expiration"], _fmt
|
|
516
|
+
)
|
|
518
517
|
self.sync_client = Client(base_url=base_url, headers=headers, proxy=self.proxy)
|
|
519
518
|
self.async_client = AsyncClient(
|
|
520
519
|
base_url=base_url, headers=headers, proxy=self.proxy
|
|
@@ -522,11 +521,7 @@ class Session:
|
|
|
522
521
|
return self
|
|
523
522
|
|
|
524
523
|
|
|
525
|
-
|
|
526
|
-
return round(time.time() * 1000)
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
class OAuthSession(Session): # pragma: no cover
|
|
524
|
+
class OAuthSession(Session):
|
|
530
525
|
"""
|
|
531
526
|
Contains a managed user login which can then be used to interact with the
|
|
532
527
|
remote API.
|
|
@@ -557,8 +552,6 @@ class OAuthSession(Session): # pragma: no cover
|
|
|
557
552
|
self.provider_secret = provider_secret
|
|
558
553
|
#: Refresh token for the user
|
|
559
554
|
self.refresh_token = refresh_token
|
|
560
|
-
#: Unix timestamp for when the session token expires
|
|
561
|
-
self.expires_at = 0
|
|
562
555
|
# The headers to use for API requests
|
|
563
556
|
headers = {
|
|
564
557
|
"Accept": "application/json",
|
|
@@ -572,13 +565,28 @@ class OAuthSession(Session): # pragma: no cover
|
|
|
572
565
|
self.async_client = AsyncClient(
|
|
573
566
|
base_url=self.sync_client.base_url, headers=headers, proxy=proxy
|
|
574
567
|
)
|
|
568
|
+
#: expiration for streamer token
|
|
569
|
+
self.streamer_expiration = datetime.now(TZ)
|
|
575
570
|
self.refresh()
|
|
576
571
|
|
|
572
|
+
def _streamer_refresh(self) -> None:
|
|
573
|
+
# Pull streamer tokens and urls
|
|
574
|
+
data = self._get("/api-quote-tokens")
|
|
575
|
+
# Auth token for dxfeed websocket
|
|
576
|
+
self.streamer_token = data["token"]
|
|
577
|
+
# URL for dxfeed websocket
|
|
578
|
+
self.dxlink_url = data["dxlink-url"]
|
|
579
|
+
self.streamer_expiration = datetime.fromisoformat(
|
|
580
|
+
data["expires-at"].replace("Z", "+00:00")
|
|
581
|
+
)
|
|
582
|
+
|
|
577
583
|
def refresh(self) -> None:
|
|
578
584
|
"""
|
|
579
585
|
Refreshes the acccess token using the stored refresh token.
|
|
586
|
+
Also refreshes the streamer token if necessary.
|
|
580
587
|
"""
|
|
581
|
-
|
|
588
|
+
request = self.sync_client.build_request(
|
|
589
|
+
"POST",
|
|
582
590
|
"/oauth/token",
|
|
583
591
|
json={
|
|
584
592
|
"grant_type": "refresh_token",
|
|
@@ -586,23 +594,32 @@ class OAuthSession(Session): # pragma: no cover
|
|
|
586
594
|
"refresh_token": self.refresh_token,
|
|
587
595
|
},
|
|
588
596
|
)
|
|
597
|
+
# Don't send the Authorization header for this request
|
|
598
|
+
request.headers.pop("Authorization", None)
|
|
599
|
+
response = self.sync_client.send(request)
|
|
589
600
|
validate_response(response)
|
|
590
601
|
data = response.json()
|
|
591
|
-
|
|
602
|
+
#: The session token used to authenticate requests
|
|
592
603
|
self.session_token = data["access_token"]
|
|
593
|
-
token_lifetime = data.get("expires_in", 900)
|
|
594
|
-
|
|
604
|
+
token_lifetime: int = data.get("expires_in", 900)
|
|
605
|
+
#: expiration for session token
|
|
606
|
+
self.session_expiration = datetime.now(TZ) + timedelta(seconds=token_lifetime)
|
|
595
607
|
logger.debug(f"Refreshed token, expires in {token_lifetime}ms")
|
|
596
608
|
auth_headers = {"Authorization": f"Bearer {self.session_token}"}
|
|
597
609
|
# update the httpx clients with the new token
|
|
598
610
|
self.sync_client.headers.update(auth_headers)
|
|
599
611
|
self.async_client.headers.update(auth_headers)
|
|
612
|
+
# update the streamer token if necessary
|
|
613
|
+
if self.streamer_expiration < self.session_expiration:
|
|
614
|
+
self._streamer_refresh()
|
|
600
615
|
|
|
601
616
|
async def a_refresh(self) -> None:
|
|
602
617
|
"""
|
|
603
618
|
Refreshes the acccess token using the stored refresh token.
|
|
619
|
+
Also refreshes the streamer token if necessary.
|
|
604
620
|
"""
|
|
605
|
-
|
|
621
|
+
request = self.async_client.build_request(
|
|
622
|
+
"POST",
|
|
606
623
|
"/oauth/token",
|
|
607
624
|
json={
|
|
608
625
|
"grant_type": "refresh_token",
|
|
@@ -610,45 +627,28 @@ class OAuthSession(Session): # pragma: no cover
|
|
|
610
627
|
"refresh_token": self.refresh_token,
|
|
611
628
|
},
|
|
612
629
|
)
|
|
630
|
+
# Don't send the Authorization header for this request
|
|
631
|
+
request.headers.pop("Authorization", None)
|
|
632
|
+
response = await self.async_client.send(request)
|
|
613
633
|
validate_response(response)
|
|
614
634
|
data = response.json()
|
|
615
635
|
# update the relevant tokens
|
|
616
636
|
self.session_token = data["access_token"]
|
|
617
|
-
token_lifetime = data.get("expires_in", 900)
|
|
618
|
-
self.
|
|
637
|
+
token_lifetime: int = data.get("expires_in", 900)
|
|
638
|
+
self.session_expiration = datetime.now(TZ) + timedelta(token_lifetime)
|
|
619
639
|
logger.debug(f"Refreshed token, expires in {token_lifetime}ms")
|
|
620
640
|
auth_headers = {"Authorization": f"Bearer {self.session_token}"}
|
|
621
641
|
# update the httpx clients with the new token
|
|
622
642
|
self.sync_client.headers.update(auth_headers)
|
|
623
643
|
self.async_client.headers.update(auth_headers)
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
@classmethod
|
|
637
|
-
def deserialize(cls, serialized: str) -> Self:
|
|
638
|
-
"""
|
|
639
|
-
Create a new Session object from a serialized string.
|
|
640
|
-
"""
|
|
641
|
-
deserialized = json.loads(serialized)
|
|
642
|
-
self = cls.__new__(cls)
|
|
643
|
-
self.__dict__ = deserialized
|
|
644
|
-
base_url = CERT_URL if self.is_test else API_URL
|
|
645
|
-
headers = {
|
|
646
|
-
"Accept": "application/json",
|
|
647
|
-
"Content-Type": "application/json",
|
|
648
|
-
"Authorization": f"Bearer {self.session_token}",
|
|
649
|
-
}
|
|
650
|
-
self.sync_client = Client(base_url=base_url, headers=headers, proxy=self.proxy)
|
|
651
|
-
self.async_client = AsyncClient(
|
|
652
|
-
base_url=base_url, headers=headers, proxy=self.proxy
|
|
653
|
-
)
|
|
654
|
-
return self
|
|
644
|
+
# update the streamer token if necessary
|
|
645
|
+
if self.streamer_expiration < self.session_expiration:
|
|
646
|
+
# Pull streamer tokens and urls
|
|
647
|
+
data = await self._a_get("/api-quote-tokens")
|
|
648
|
+
# Auth token for dxfeed websocket
|
|
649
|
+
self.streamer_token = data["token"]
|
|
650
|
+
# URL for dxfeed websocket
|
|
651
|
+
self.dxlink_url = data["dxlink-url"]
|
|
652
|
+
self.streamer_expiration = datetime.fromisoformat(
|
|
653
|
+
data["expires-at"].replace("Z", "+00:00")
|
|
654
|
+
)
|
|
@@ -252,6 +252,7 @@ class AlertStreamer:
|
|
|
252
252
|
self._reconnect_task: Optional[asyncio.Task[None]] = None
|
|
253
253
|
self._heartbeat_task: Optional[asyncio.Task[None]] = None
|
|
254
254
|
self._closing = False
|
|
255
|
+
self._tasks: set[asyncio.Task[Any]] = set()
|
|
255
256
|
|
|
256
257
|
async def __aenter__(self) -> AlertStreamer:
|
|
257
258
|
time_out = 100
|
|
@@ -278,17 +279,18 @@ class AlertStreamer:
|
|
|
278
279
|
"""
|
|
279
280
|
Closes the websocket connection and cancels the pending tasks.
|
|
280
281
|
"""
|
|
281
|
-
self._closing
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
self._heartbeat_task.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
self._reconnect_task.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
282
|
+
if not self._closing: # can only be called once
|
|
283
|
+
self._closing = True
|
|
284
|
+
self._connect_task.cancel()
|
|
285
|
+
tasks = [self._connect_task]
|
|
286
|
+
if self._heartbeat_task and not self._heartbeat_task.done():
|
|
287
|
+
self._heartbeat_task.cancel()
|
|
288
|
+
tasks.append(self._heartbeat_task)
|
|
289
|
+
if self._reconnect_task and not self._reconnect_task.done():
|
|
290
|
+
self._reconnect_task.cancel()
|
|
291
|
+
tasks.append(self._reconnect_task)
|
|
292
|
+
await asyncio.gather(*tasks)
|
|
293
|
+
await self._websocket.wait_closed() # type: ignore
|
|
292
294
|
|
|
293
295
|
async def _connect(self) -> None:
|
|
294
296
|
"""
|
|
@@ -316,11 +318,11 @@ class AlertStreamer:
|
|
|
316
318
|
logger.error(f"Websocket connection closed with {e}")
|
|
317
319
|
except asyncio.CancelledError:
|
|
318
320
|
logger.debug("Websocket interrupted, cancelling main loop.")
|
|
319
|
-
|
|
320
|
-
await self.close()
|
|
321
|
-
return
|
|
321
|
+
return await self.close()
|
|
322
322
|
finally:
|
|
323
|
-
|
|
323
|
+
self._tasks.add( # prevent garbage collection
|
|
324
|
+
asyncio.create_task(self.disconnect_fn(self, *self.disconnect_args))
|
|
325
|
+
)
|
|
324
326
|
logger.debug("Websocket connection closed, retrying...")
|
|
325
327
|
reconnecting = True
|
|
326
328
|
|
|
@@ -488,6 +490,7 @@ class DXLinkStreamer:
|
|
|
488
490
|
self._reconnect_task: Optional[asyncio.Task[None]] = None
|
|
489
491
|
self._closing = False
|
|
490
492
|
self._websocket: ClientConnection
|
|
493
|
+
self._tasks: set[asyncio.Task[Any]] = set()
|
|
491
494
|
|
|
492
495
|
async def __aenter__(self) -> DXLinkStreamer:
|
|
493
496
|
self._connect_task = asyncio.create_task(self._connect())
|
|
@@ -515,17 +518,18 @@ class DXLinkStreamer:
|
|
|
515
518
|
"""
|
|
516
519
|
Closes the websocket connection and cancels the heartbeat task.
|
|
517
520
|
"""
|
|
518
|
-
self._closing
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
self._heartbeat_task
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
self._reconnect_task.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
521
|
+
if not self._closing: # can only be called once
|
|
522
|
+
self._closing = True
|
|
523
|
+
self._connect_task.cancel()
|
|
524
|
+
tasks = [self._connect_task]
|
|
525
|
+
if self._heartbeat_task:
|
|
526
|
+
self._heartbeat_task.cancel()
|
|
527
|
+
tasks.append(self._heartbeat_task)
|
|
528
|
+
if self._reconnect_task is not None and not self._reconnect_task.done():
|
|
529
|
+
self._reconnect_task.cancel()
|
|
530
|
+
tasks.append(self._reconnect_task)
|
|
531
|
+
await asyncio.gather(*tasks)
|
|
532
|
+
await self._websocket.wait_closed()
|
|
529
533
|
|
|
530
534
|
async def _connect(self) -> None:
|
|
531
535
|
"""
|
|
@@ -592,8 +596,7 @@ class DXLinkStreamer:
|
|
|
592
596
|
pass
|
|
593
597
|
elif message["type"] == "ERROR":
|
|
594
598
|
logger.error(f"Fatal streamer error: {message['message']}")
|
|
595
|
-
await self.close()
|
|
596
|
-
return
|
|
599
|
+
return await self.close()
|
|
597
600
|
else:
|
|
598
601
|
logger.error(f"Unknown message: {message}")
|
|
599
602
|
except ConnectionClosed as e:
|
|
@@ -603,15 +606,14 @@ class DXLinkStreamer:
|
|
|
603
606
|
"Subscription message too long! Try reducing the number of "
|
|
604
607
|
"symbols."
|
|
605
608
|
)
|
|
606
|
-
await self.close()
|
|
607
|
-
return
|
|
609
|
+
return await self.close()
|
|
608
610
|
except asyncio.CancelledError:
|
|
609
611
|
logger.debug("Websocket interrupted, cancelling main loop.")
|
|
610
|
-
|
|
611
|
-
await self.close()
|
|
612
|
-
return
|
|
612
|
+
return await self.close()
|
|
613
613
|
finally:
|
|
614
|
-
|
|
614
|
+
self._tasks.add( # prevent garbage collection
|
|
615
|
+
asyncio.create_task(self.disconnect_fn(self, *self.disconnect_args))
|
|
616
|
+
)
|
|
615
617
|
logger.debug("Websocket connection closed, retrying...")
|
|
616
618
|
reconnecting = True
|
|
617
619
|
|
|
@@ -817,7 +819,6 @@ class DXLinkStreamer:
|
|
|
817
819
|
the width of each candle in time, e.g. '15s', '5m', '1h', '3d',
|
|
818
820
|
'1w', '1mo'
|
|
819
821
|
:param start_time: starting time for the data range
|
|
820
|
-
:param end_time: ending time for the data range
|
|
821
822
|
:param extended_trading_hours: whether to include extended trading
|
|
822
823
|
:param refresh_interval:
|
|
823
824
|
Time in seconds between fetching new events from dxfeed for this event type.
|
|
@@ -828,6 +829,7 @@ class DXLinkStreamer:
|
|
|
828
829
|
cls_str = "Candle"
|
|
829
830
|
if self._subscription_state[cls_str] != "CHANNEL_OPENED":
|
|
830
831
|
await self._channel_request(cls_str, refresh_interval)
|
|
832
|
+
ts = int(start_time.timestamp() * 1000)
|
|
831
833
|
message = {
|
|
832
834
|
"type": "FEED_SUBSCRIPTION",
|
|
833
835
|
"channel": self._channels[cls_str],
|
|
@@ -839,7 +841,7 @@ class DXLinkStreamer:
|
|
|
839
841
|
else f"{ticker}{{={interval},tho=true}}"
|
|
840
842
|
),
|
|
841
843
|
"type": "Candle",
|
|
842
|
-
"fromTime":
|
|
844
|
+
"fromTime": ts,
|
|
843
845
|
}
|
|
844
846
|
for ticker in symbols
|
|
845
847
|
],
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from datetime import date, datetime, timedelta
|
|
2
2
|
from decimal import Decimal
|
|
3
3
|
from enum import Enum
|
|
4
|
+
from json import JSONDecodeError
|
|
4
5
|
from typing import Any, Optional, cast
|
|
5
6
|
from zoneinfo import ZoneInfo
|
|
6
7
|
|
|
@@ -50,8 +51,7 @@ def is_market_open_on(day: Optional[date] = None) -> bool:
|
|
|
50
51
|
|
|
51
52
|
:return: whether the market opens on given day
|
|
52
53
|
"""
|
|
53
|
-
|
|
54
|
-
day = today_in_new_york()
|
|
54
|
+
day = day or today_in_new_york()
|
|
55
55
|
date_range = NYSE.valid_days(day, day)
|
|
56
56
|
return not date_range.empty
|
|
57
57
|
|
|
@@ -65,10 +65,7 @@ def get_third_friday(day: Optional[date] = None) -> date:
|
|
|
65
65
|
|
|
66
66
|
:return: the associated monthly
|
|
67
67
|
"""
|
|
68
|
-
|
|
69
|
-
day = today_in_new_york()
|
|
70
|
-
day = day.replace(day=1)
|
|
71
|
-
day += timedelta(weeks=2)
|
|
68
|
+
day = (day or today_in_new_york()).replace(day=1) + timedelta(weeks=2)
|
|
72
69
|
while day.weekday() != 4: # Friday
|
|
73
70
|
day += timedelta(days=1)
|
|
74
71
|
return day
|
|
@@ -105,10 +102,7 @@ def get_future_fx_monthly(day: Optional[date] = None) -> date:
|
|
|
105
102
|
|
|
106
103
|
:return: the associated monthly
|
|
107
104
|
"""
|
|
108
|
-
|
|
109
|
-
day = today_in_new_york()
|
|
110
|
-
day = day.replace(day=1)
|
|
111
|
-
day += timedelta(weeks=1)
|
|
105
|
+
day = (day or today_in_new_york()).replace(day=1) + timedelta(weeks=1)
|
|
112
106
|
while day.weekday() != 2: # Wednesday
|
|
113
107
|
day += timedelta(days=1)
|
|
114
108
|
while day.weekday() != 4: # Friday
|
|
@@ -127,8 +121,7 @@ def get_future_treasury_monthly(day: Optional[date] = None) -> date:
|
|
|
127
121
|
|
|
128
122
|
:return: the associated monthly
|
|
129
123
|
"""
|
|
130
|
-
|
|
131
|
-
day = today_in_new_york()
|
|
124
|
+
day = day or today_in_new_york()
|
|
132
125
|
last_day = _get_last_day_of_month(day)
|
|
133
126
|
first_day = last_day.replace(day=1)
|
|
134
127
|
valid_range: list[date] = [d.date() for d in NYSE.valid_days(first_day, last_day)]
|
|
@@ -151,8 +144,7 @@ def get_future_metal_monthly(day: Optional[date] = None) -> date:
|
|
|
151
144
|
|
|
152
145
|
:return: the associated monthly
|
|
153
146
|
"""
|
|
154
|
-
|
|
155
|
-
day = today_in_new_york()
|
|
147
|
+
day = day or today_in_new_york()
|
|
156
148
|
last_day = _get_last_day_of_month(day)
|
|
157
149
|
first_day = last_day.replace(day=1)
|
|
158
150
|
valid_range: list[date] = [d.date() for d in NYSE.valid_days(first_day, last_day)]
|
|
@@ -173,8 +165,7 @@ def get_future_grain_monthly(day: Optional[date] = None) -> date:
|
|
|
173
165
|
|
|
174
166
|
:return: the associated monthly
|
|
175
167
|
"""
|
|
176
|
-
|
|
177
|
-
day = today_in_new_york()
|
|
168
|
+
day = day or today_in_new_york()
|
|
178
169
|
last_day = _get_last_day_of_month(day)
|
|
179
170
|
first_day = last_day.replace(day=1)
|
|
180
171
|
valid_range: list[date] = [d.date() for d in NYSE.valid_days(first_day, last_day)]
|
|
@@ -195,9 +186,7 @@ def get_future_oil_monthly(day: Optional[date] = None) -> date:
|
|
|
195
186
|
|
|
196
187
|
:return: the associated monthly
|
|
197
188
|
"""
|
|
198
|
-
|
|
199
|
-
day = today_in_new_york()
|
|
200
|
-
last_day = day.replace(day=25)
|
|
189
|
+
last_day = (day or today_in_new_york()).replace(day=25)
|
|
201
190
|
first_day = last_day.replace(day=1)
|
|
202
191
|
valid_range: list[date] = [d.date() for d in NYSE.valid_days(first_day, last_day)]
|
|
203
192
|
return valid_range[-7]
|
|
@@ -213,8 +202,7 @@ def get_future_index_monthly(day: Optional[date] = None) -> date:
|
|
|
213
202
|
|
|
214
203
|
:return: the associated monthly
|
|
215
204
|
"""
|
|
216
|
-
|
|
217
|
-
day = today_in_new_york()
|
|
205
|
+
day = day or today_in_new_york()
|
|
218
206
|
last_day = _get_last_day_of_month(day)
|
|
219
207
|
first_day = last_day.replace(day=1)
|
|
220
208
|
valid_range: list[date] = [d.date() for d in NYSE.valid_days(first_day, last_day)]
|
|
@@ -262,29 +250,29 @@ def validate_response(response: Response) -> None:
|
|
|
262
250
|
:param response: response to check for errors
|
|
263
251
|
"""
|
|
264
252
|
if response.status_code // 100 != 2:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
253
|
+
try:
|
|
254
|
+
json: dict[str, Any] = response.json()
|
|
255
|
+
except JSONDecodeError as e:
|
|
256
|
+
raise TastytradeError(f"Couldn't parse response: {response.text}") from e
|
|
257
|
+
if not (content := json.get("error")):
|
|
268
258
|
raise TastytradeError(f"Couldn't parse response: {json}")
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
error_message += f"\n{error['domain']}: {error['reason']}"
|
|
259
|
+
errors = content.get("errors") or [content]
|
|
260
|
+
message = ""
|
|
261
|
+
for error in errors:
|
|
262
|
+
if "code" in error:
|
|
263
|
+
message += f"{error['code']}: {error['message']}\n"
|
|
264
|
+
else:
|
|
265
|
+
message += f"{error['domain']}: {error['reason']}\n"
|
|
277
266
|
|
|
278
|
-
raise TastytradeError(
|
|
267
|
+
raise TastytradeError(message)
|
|
279
268
|
|
|
280
269
|
|
|
281
270
|
def validate_and_parse(response: Response) -> dict[str, Any]:
|
|
282
271
|
validate_response(response)
|
|
283
272
|
json = response.json()
|
|
284
|
-
|
|
285
|
-
if data is None:
|
|
273
|
+
if not (data := json.get("data")):
|
|
286
274
|
raise TastytradeError(f"No data present in response: {json}")
|
|
287
|
-
return data
|
|
275
|
+
return cast(dict[str, Any], data)
|
|
288
276
|
|
|
289
277
|
|
|
290
278
|
def get_sign(value: Optional[Decimal]) -> Optional[PriceEffect]:
|
|
@@ -304,7 +292,6 @@ def set_sign_for(data: Any, properties: list[str]) -> Any:
|
|
|
304
292
|
data = cast(dict[str, Any], data)
|
|
305
293
|
for property in properties:
|
|
306
294
|
key = _dasherize(property)
|
|
307
|
-
|
|
308
|
-
if effect == PriceEffect.DEBIT:
|
|
295
|
+
if data.get(f"{key}-effect") == PriceEffect.DEBIT:
|
|
309
296
|
data[key] = -abs(Decimal(data[key]))
|
|
310
297
|
return data
|
|
@@ -17,7 +17,10 @@ async def test_backtest_simple(session: Session):
|
|
|
17
17
|
symbol="SPY",
|
|
18
18
|
entry_conditions=BacktestEntry(),
|
|
19
19
|
exit_conditions=BacktestExit(at_days_to_expiration=21),
|
|
20
|
-
legs=[
|
|
20
|
+
legs=[
|
|
21
|
+
BacktestLeg(direction="short"),
|
|
22
|
+
BacktestLeg(direction="long", side="put"),
|
|
23
|
+
],
|
|
21
24
|
start_date=today_in_new_york() - timedelta(days=365),
|
|
22
25
|
)
|
|
23
26
|
results = [r async for r in backtest_session.run(backtest)]
|
|
@@ -147,7 +147,10 @@ async def test_get_option_chain_async(session: Session):
|
|
|
147
147
|
chain = await a_get_option_chain(session, "SPY")
|
|
148
148
|
assert chain != {}
|
|
149
149
|
for options in chain.values():
|
|
150
|
-
await Option.a_get(session, options[0].symbol)
|
|
150
|
+
single = await Option.a_get(session, options[0].symbol)
|
|
151
|
+
multiple = await Option.a_get(session, [options[0].symbol, options[1].symbol])
|
|
152
|
+
assert isinstance(single, Option)
|
|
153
|
+
assert isinstance(multiple, list)
|
|
151
154
|
break
|
|
152
155
|
|
|
153
156
|
|
|
@@ -155,7 +158,14 @@ def test_get_option_chain(session: Session):
|
|
|
155
158
|
chain = get_option_chain(session, "SPY")
|
|
156
159
|
assert chain != {}
|
|
157
160
|
for options in chain.values():
|
|
158
|
-
Option.get(session, options[0].symbol)
|
|
161
|
+
single = Option.get(session, options[0].symbol)
|
|
162
|
+
# test setting symbol
|
|
163
|
+
old = single.streamer_symbol
|
|
164
|
+
single._set_streamer_symbol()
|
|
165
|
+
assert single.streamer_symbol == old
|
|
166
|
+
multiple = Option.get(session, [options[0].symbol, options[1].symbol])
|
|
167
|
+
assert isinstance(single, Option)
|
|
168
|
+
assert isinstance(multiple, list)
|
|
159
169
|
break
|
|
160
170
|
|
|
161
171
|
|
|
@@ -3,7 +3,7 @@ import os
|
|
|
3
3
|
import pytest
|
|
4
4
|
from proxy import TestCase
|
|
5
5
|
|
|
6
|
-
from tastytrade import Session
|
|
6
|
+
from tastytrade import Account, OAuthSession, Session
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def test_get_customer(session: Session):
|
|
@@ -56,3 +56,26 @@ def test_cert_session():
|
|
|
56
56
|
assert username and password
|
|
57
57
|
session = Session(username, password, is_test=True)
|
|
58
58
|
session.destroy()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.fixture(scope="module")
|
|
62
|
+
async def oauth(aiolib: str) -> OAuthSession:
|
|
63
|
+
refresh = os.getenv("TT_REFRESH")
|
|
64
|
+
secret = os.getenv("TT_SECRET")
|
|
65
|
+
assert refresh and secret
|
|
66
|
+
return OAuthSession(secret, refresh)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_oauth_refresh(oauth: OAuthSession):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_oauth_serialization(oauth: OAuthSession):
|
|
74
|
+
session_str = oauth.serialize()
|
|
75
|
+
session2 = OAuthSession.deserialize(session_str)
|
|
76
|
+
print(oauth.session_token == session2.session_token)
|
|
77
|
+
Account.get(session2)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def test_oauth_refresh_async(oauth: OAuthSession):
|
|
81
|
+
await oauth.a_refresh()
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
Sessions
|
|
2
|
-
========
|
|
3
|
-
|
|
4
|
-
Creating a session
|
|
5
|
-
------------------
|
|
6
|
-
|
|
7
|
-
A session object is required to authenticate your requests to the Tastytrade API.
|
|
8
|
-
To create a production (real) session using your normal login:
|
|
9
|
-
|
|
10
|
-
.. code-block:: python
|
|
11
|
-
|
|
12
|
-
from tastytrade import Session
|
|
13
|
-
session = Session('username', 'password')
|
|
14
|
-
|
|
15
|
-
A certification (test) account can be created `here <https://developer.tastytrade.com/sandbox/>`_, then used to create a session.
|
|
16
|
-
|
|
17
|
-
.. code-block:: python
|
|
18
|
-
|
|
19
|
-
from tastytrade import Session
|
|
20
|
-
session = Session('username', 'password', is_test=True)
|
|
21
|
-
|
|
22
|
-
You can make a session persistent by generating a remember token, which is valid for 24 hours:
|
|
23
|
-
|
|
24
|
-
.. code-block:: python
|
|
25
|
-
|
|
26
|
-
session = Session('username', 'password', remember_me=True)
|
|
27
|
-
remember_token = session.remember_token
|
|
28
|
-
# remember token replaces the password for the next login
|
|
29
|
-
new_session = Session('username', remember_token=remember_token)
|
|
30
|
-
|
|
31
|
-
.. note::
|
|
32
|
-
If you used a certification (test) account to create the session associated with the `remember_token`, you must set `is_test=True` when creating subsequent sessions.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|