tastytrade 10.2.3__py3-none-any.whl → 10.3.0__py3-none-any.whl
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/__init__.py +3 -2
- tastytrade/account.py +9 -8
- tastytrade/backtest.py +2 -2
- tastytrade/market_data.py +1 -0
- tastytrade/oauth.py +129 -0
- tastytrade/session.py +52 -60
- tastytrade/streamer.py +15 -15
- tastytrade/utils.py +25 -38
- {tastytrade-10.2.3.dist-info → tastytrade-10.3.0.dist-info}/METADATA +1 -1
- {tastytrade-10.2.3.dist-info → tastytrade-10.3.0.dist-info}/RECORD +12 -11
- {tastytrade-10.2.3.dist-info → tastytrade-10.3.0.dist-info}/WHEEL +0 -0
- {tastytrade-10.2.3.dist-info → tastytrade-10.3.0.dist-info}/licenses/LICENSE +0 -0
tastytrade/__init__.py
CHANGED
|
@@ -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
|
]
|
tastytrade/account.py
CHANGED
|
@@ -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]:
|
tastytrade/backtest.py
CHANGED
|
@@ -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
|
|
tastytrade/market_data.py
CHANGED
tastytrade/oauth.py
ADDED
|
@@ -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()
|
tastytrade/session.py
CHANGED
|
@@ -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,11 +565,25 @@ 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(
|
|
582
589
|
"POST",
|
|
@@ -592,19 +599,24 @@ class OAuthSession(Session): # pragma: no cover
|
|
|
592
599
|
response = self.sync_client.send(request)
|
|
593
600
|
validate_response(response)
|
|
594
601
|
data = response.json()
|
|
595
|
-
|
|
602
|
+
#: The session token used to authenticate requests
|
|
596
603
|
self.session_token = data["access_token"]
|
|
597
|
-
token_lifetime = data.get("expires_in", 900)
|
|
598
|
-
|
|
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)
|
|
599
607
|
logger.debug(f"Refreshed token, expires in {token_lifetime}ms")
|
|
600
608
|
auth_headers = {"Authorization": f"Bearer {self.session_token}"}
|
|
601
609
|
# update the httpx clients with the new token
|
|
602
610
|
self.sync_client.headers.update(auth_headers)
|
|
603
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()
|
|
604
615
|
|
|
605
616
|
async def a_refresh(self) -> None:
|
|
606
617
|
"""
|
|
607
618
|
Refreshes the acccess token using the stored refresh token.
|
|
619
|
+
Also refreshes the streamer token if necessary.
|
|
608
620
|
"""
|
|
609
621
|
request = self.async_client.build_request(
|
|
610
622
|
"POST",
|
|
@@ -622,41 +634,21 @@ class OAuthSession(Session): # pragma: no cover
|
|
|
622
634
|
data = response.json()
|
|
623
635
|
# update the relevant tokens
|
|
624
636
|
self.session_token = data["access_token"]
|
|
625
|
-
token_lifetime = data.get("expires_in", 900)
|
|
626
|
-
self.
|
|
637
|
+
token_lifetime: int = data.get("expires_in", 900)
|
|
638
|
+
self.session_expiration = datetime.now(TZ) + timedelta(token_lifetime)
|
|
627
639
|
logger.debug(f"Refreshed token, expires in {token_lifetime}ms")
|
|
628
640
|
auth_headers = {"Authorization": f"Bearer {self.session_token}"}
|
|
629
641
|
# update the httpx clients with the new token
|
|
630
642
|
self.sync_client.headers.update(auth_headers)
|
|
631
643
|
self.async_client.headers.update(auth_headers)
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
@classmethod
|
|
645
|
-
def deserialize(cls, serialized: str) -> Self:
|
|
646
|
-
"""
|
|
647
|
-
Create a new Session object from a serialized string.
|
|
648
|
-
"""
|
|
649
|
-
deserialized = json.loads(serialized)
|
|
650
|
-
self = cls.__new__(cls)
|
|
651
|
-
self.__dict__ = deserialized
|
|
652
|
-
base_url = CERT_URL if self.is_test else API_URL
|
|
653
|
-
headers = {
|
|
654
|
-
"Accept": "application/json",
|
|
655
|
-
"Content-Type": "application/json",
|
|
656
|
-
"Authorization": f"Bearer {self.session_token}",
|
|
657
|
-
}
|
|
658
|
-
self.sync_client = Client(base_url=base_url, headers=headers, proxy=self.proxy)
|
|
659
|
-
self.async_client = AsyncClient(
|
|
660
|
-
base_url=base_url, headers=headers, proxy=self.proxy
|
|
661
|
-
)
|
|
662
|
-
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
|
+
)
|
tastytrade/streamer.py
CHANGED
|
@@ -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
|
|
@@ -319,7 +320,9 @@ class AlertStreamer:
|
|
|
319
320
|
logger.debug("Websocket interrupted, cancelling main loop.")
|
|
320
321
|
return await self.close()
|
|
321
322
|
finally:
|
|
322
|
-
|
|
323
|
+
self._tasks.add( # prevent garbage collection
|
|
324
|
+
asyncio.create_task(self.disconnect_fn(self, *self.disconnect_args))
|
|
325
|
+
)
|
|
323
326
|
logger.debug("Websocket connection closed, retrying...")
|
|
324
327
|
reconnecting = True
|
|
325
328
|
|
|
@@ -335,12 +338,9 @@ class AlertStreamer:
|
|
|
335
338
|
the type of alert to listen for, should be of :any:`AlertType`
|
|
336
339
|
"""
|
|
337
340
|
cls_str = next(k for k, v in MAP_ALERTS.items() if v == alert_class)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
yield cast(T, item)
|
|
342
|
-
except GeneratorExit: # no cleanup needed
|
|
343
|
-
pass
|
|
341
|
+
while True:
|
|
342
|
+
item = await self._queues[cls_str].get()
|
|
343
|
+
yield cast(T, item)
|
|
344
344
|
|
|
345
345
|
async def _map_message(self, type_str: str, data: dict[str, Any]) -> None:
|
|
346
346
|
"""
|
|
@@ -490,6 +490,7 @@ class DXLinkStreamer:
|
|
|
490
490
|
self._reconnect_task: Optional[asyncio.Task[None]] = None
|
|
491
491
|
self._closing = False
|
|
492
492
|
self._websocket: ClientConnection
|
|
493
|
+
self._tasks: set[asyncio.Task[Any]] = set()
|
|
493
494
|
|
|
494
495
|
async def __aenter__(self) -> DXLinkStreamer:
|
|
495
496
|
self._connect_task = asyncio.create_task(self._connect())
|
|
@@ -610,7 +611,9 @@ class DXLinkStreamer:
|
|
|
610
611
|
logger.debug("Websocket interrupted, cancelling main loop.")
|
|
611
612
|
return await self.close()
|
|
612
613
|
finally:
|
|
613
|
-
|
|
614
|
+
self._tasks.add( # prevent garbage collection
|
|
615
|
+
asyncio.create_task(self.disconnect_fn(self, *self.disconnect_args))
|
|
616
|
+
)
|
|
614
617
|
logger.debug("Websocket connection closed, retrying...")
|
|
615
618
|
reconnecting = True
|
|
616
619
|
|
|
@@ -644,11 +647,8 @@ class DXLinkStreamer:
|
|
|
644
647
|
:param event_class:
|
|
645
648
|
the type of alert to listen for, should be of :any:`EventType`
|
|
646
649
|
"""
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
yield await self._queues[MAP_EVENTS_REVERSE[event_class]].get() # type: ignore
|
|
650
|
-
except GeneratorExit: # no cleanup needed
|
|
651
|
-
pass
|
|
650
|
+
while True:
|
|
651
|
+
yield await self._queues[MAP_EVENTS_REVERSE[event_class]].get() # type: ignore
|
|
652
652
|
|
|
653
653
|
def get_event_nowait(self, event_class: type[U]) -> Optional[U]:
|
|
654
654
|
"""
|
|
@@ -819,7 +819,6 @@ class DXLinkStreamer:
|
|
|
819
819
|
the width of each candle in time, e.g. '15s', '5m', '1h', '3d',
|
|
820
820
|
'1w', '1mo'
|
|
821
821
|
:param start_time: starting time for the data range
|
|
822
|
-
:param end_time: ending time for the data range
|
|
823
822
|
:param extended_trading_hours: whether to include extended trading
|
|
824
823
|
:param refresh_interval:
|
|
825
824
|
Time in seconds between fetching new events from dxfeed for this event type.
|
|
@@ -830,6 +829,7 @@ class DXLinkStreamer:
|
|
|
830
829
|
cls_str = "Candle"
|
|
831
830
|
if self._subscription_state[cls_str] != "CHANNEL_OPENED":
|
|
832
831
|
await self._channel_request(cls_str, refresh_interval)
|
|
832
|
+
ts = int(start_time.timestamp() * 1000)
|
|
833
833
|
message = {
|
|
834
834
|
"type": "FEED_SUBSCRIPTION",
|
|
835
835
|
"channel": self._channels[cls_str],
|
|
@@ -841,7 +841,7 @@ class DXLinkStreamer:
|
|
|
841
841
|
else f"{ticker}{{={interval},tho=true}}"
|
|
842
842
|
),
|
|
843
843
|
"type": "Candle",
|
|
844
|
-
"fromTime":
|
|
844
|
+
"fromTime": ts,
|
|
845
845
|
}
|
|
846
846
|
for ticker in symbols
|
|
847
847
|
],
|
tastytrade/utils.py
CHANGED
|
@@ -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
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
tastytrade/__init__.py,sha256=
|
|
2
|
-
tastytrade/account.py,sha256=
|
|
3
|
-
tastytrade/backtest.py,sha256
|
|
1
|
+
tastytrade/__init__.py,sha256=SBz8wUn30LFp8PuorDEK03UlqWqFw7D9MfAHKWmeHX0,615
|
|
2
|
+
tastytrade/account.py,sha256=biQRBAEiyo1AVbeTkTgmPkxN0M7Hura5ZzMzEyeEfV4,63636
|
|
3
|
+
tastytrade/backtest.py,sha256=mDwmRYLr2Fv_0rFVEJl-sK8vzTRuZd_RD2y2Tk-kJUQ,7918
|
|
4
4
|
tastytrade/instruments.py,sha256=DX8gc81xhkKRRgoL7-g2aWXo_WX30N_3SmRSuYMZBxM,47062
|
|
5
|
-
tastytrade/market_data.py,sha256=
|
|
5
|
+
tastytrade/market_data.py,sha256=0NxUMvUkFGxzhce1d0DW4WkG1ggu6CLRZK8TgKJxygo,6091
|
|
6
6
|
tastytrade/market_sessions.py,sha256=qTzdiey042SJ-dqFOJiGFQuRkJb-JXxmoyLSoNXHCM8,3431
|
|
7
7
|
tastytrade/metrics.py,sha256=yKZ1EoiQgCLEfdTF-sC9U_pgpeG27Bq717I-FQg1RZc,7256
|
|
8
|
+
tastytrade/oauth.py,sha256=UePuYKwXfwgZ1gTwDa9-pAEYM9xnFWxgsF5v_3va314,4564
|
|
8
9
|
tastytrade/order.py,sha256=C7Eyn2uDCY5Ss3AG5xk5mnHu1W58P1Ji7pJEMAHHWiU,15107
|
|
9
10
|
tastytrade/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
11
|
tastytrade/search.py,sha256=LdoVEhiYNvtolXlf_jAVZUtA2ymUWOvHMhQxJxuVt_A,1529
|
|
11
|
-
tastytrade/session.py,sha256=
|
|
12
|
-
tastytrade/streamer.py,sha256
|
|
13
|
-
tastytrade/utils.py,sha256=
|
|
12
|
+
tastytrade/session.py,sha256=XbB3Q3L4wQQChB2Su9O9c6j7FCH_1CaIHKYHX5dSFtE,21308
|
|
13
|
+
tastytrade/streamer.py,sha256=7ZfuZDg4z8e-WHq5dCA9EkJK3c5jxM6EJiMTU8u_q-Y,33035
|
|
14
|
+
tastytrade/utils.py,sha256=ZEvaaZPJ2uouXvb8ou_8nW3o_kKHyESgjeHyFbQ3KCg,9560
|
|
14
15
|
tastytrade/watchlists.py,sha256=rpZmtl-jGJVNXT_L9oD3khwiKKUy-0ilAixkMNC11uI,8731
|
|
15
16
|
tastytrade/dxfeed/__init__.py,sha256=GmC0aKtiUjs7aqbX7PeqMaROxqalwzHOnJOMJn8TaZk,458
|
|
16
17
|
tastytrade/dxfeed/candle.py,sha256=j9nuWftzOT_qGDTZNNfFIABZp_n_5Gi7OFm5KPK2dnc,1757
|
|
@@ -23,7 +24,7 @@ tastytrade/dxfeed/theoprice.py,sha256=L5aH--F_6xLZCSYZ4APpzlihbW0-cYEwRdeGVI-aNa
|
|
|
23
24
|
tastytrade/dxfeed/timeandsale.py,sha256=QuMFoccq8x3c2y6s3DnwBNIVTrLS6OPqV6GmCNoXQEQ,1903
|
|
24
25
|
tastytrade/dxfeed/trade.py,sha256=qNo4oKb7iq0Opoq3FCBEUUcGGF6udda1bD0eKQVty_0,1402
|
|
25
26
|
tastytrade/dxfeed/underlying.py,sha256=YYqJNlmrlt6Kpg0F6voQ18g60obXiYTVlroXirBWPR8,1226
|
|
26
|
-
tastytrade-10.
|
|
27
|
-
tastytrade-10.
|
|
28
|
-
tastytrade-10.
|
|
29
|
-
tastytrade-10.
|
|
27
|
+
tastytrade-10.3.0.dist-info/METADATA,sha256=SEgnr-bdaRlpt0v6XHKqu84MQmEs8MvbHtJ_t9yR330,10903
|
|
28
|
+
tastytrade-10.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
29
|
+
tastytrade-10.3.0.dist-info/licenses/LICENSE,sha256=enBkMN4OsfLt6Z_AsrGC7u5dAJkCEODnoN7BwMCzSfc,1072
|
|
30
|
+
tastytrade-10.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|