tastytrade 11.0.3__py3-none-any.whl → 11.0.5__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 +1 -1
- tastytrade/account.py +0 -2
- tastytrade/dxfeed/event.py +0 -2
- tastytrade/instruments.py +1 -1
- tastytrade/order.py +6 -4
- tastytrade/session.py +12 -27
- tastytrade/utils.py +0 -24
- {tastytrade-11.0.3.dist-info → tastytrade-11.0.5.dist-info}/METADATA +1 -1
- {tastytrade-11.0.3.dist-info → tastytrade-11.0.5.dist-info}/RECORD +11 -12
- {tastytrade-11.0.3.dist-info → tastytrade-11.0.5.dist-info}/WHEEL +1 -1
- tastytrade/oauth.py +0 -129
- {tastytrade-11.0.3.dist-info → tastytrade-11.0.5.dist-info}/licenses/LICENSE +0 -0
tastytrade/__init__.py
CHANGED
|
@@ -4,7 +4,7 @@ API_URL = "https://api.tastyworks.com"
|
|
|
4
4
|
API_VERSION = "20251101"
|
|
5
5
|
CERT_URL = "https://api.cert.tastyworks.com"
|
|
6
6
|
VAST_URL = "https://vast.tastyworks.com"
|
|
7
|
-
VERSION = "11.0.
|
|
7
|
+
VERSION = "11.0.5"
|
|
8
8
|
|
|
9
9
|
__version__ = VERSION
|
|
10
10
|
version_str: str = f"tastyware/tastytrade:v{VERSION}"
|
tastytrade/account.py
CHANGED
|
@@ -546,8 +546,6 @@ class Account(TastytradeData):
|
|
|
546
546
|
:param session: the session to use for the request.
|
|
547
547
|
:param account_number: the account ID to get.
|
|
548
548
|
:param include_closed: whether to include closed accounts in the results
|
|
549
|
-
|
|
550
|
-
:return: an account if an ID was provided; otherwise, a single account.
|
|
551
549
|
"""
|
|
552
550
|
if account_number:
|
|
553
551
|
data = session._get(f"/customers/me/accounts/{account_number}")
|
tastytrade/dxfeed/event.py
CHANGED
tastytrade/instruments.py
CHANGED
|
@@ -490,8 +490,8 @@ class Option(TradeableTastytradeData):
|
|
|
490
490
|
stops_trading_at: datetime
|
|
491
491
|
market_time_instrument_collection: str
|
|
492
492
|
days_to_expiration: int
|
|
493
|
-
expires_at: datetime
|
|
494
493
|
is_closing_only: bool
|
|
494
|
+
expires_at: datetime | None = None
|
|
495
495
|
streamer_symbol: str = ""
|
|
496
496
|
listed_market: str | None = None
|
|
497
497
|
halted_at: datetime | None = None
|
tastytrade/order.py
CHANGED
|
@@ -133,7 +133,7 @@ class Leg(TastytradeData):
|
|
|
133
133
|
instrument_type: InstrumentType
|
|
134
134
|
symbol: str
|
|
135
135
|
action: OrderAction
|
|
136
|
-
quantity: Decimal | None = None
|
|
136
|
+
quantity: Decimal | int | None = None
|
|
137
137
|
remaining_quantity: Decimal | None = None
|
|
138
138
|
fills: list[FillInfo] | None = None
|
|
139
139
|
|
|
@@ -149,15 +149,13 @@ class TradeableTastytradeData(TastytradeData):
|
|
|
149
149
|
instrument_type: InstrumentType
|
|
150
150
|
symbol: str
|
|
151
151
|
|
|
152
|
-
def build_leg(self, quantity: Decimal | None, action: OrderAction) -> Leg:
|
|
152
|
+
def build_leg(self, quantity: Decimal | int | None, action: OrderAction) -> Leg:
|
|
153
153
|
"""
|
|
154
154
|
Builds an order :class:`Leg` from the dataclass.
|
|
155
155
|
|
|
156
156
|
:param quantity:
|
|
157
157
|
the quantity of the symbol to trade, set this as `None` for notional orders
|
|
158
158
|
:param action: :class:`OrderAction` to perform, e.g. BUY_TO_OPEN
|
|
159
|
-
|
|
160
|
-
:return: a :class:`Leg` object
|
|
161
159
|
"""
|
|
162
160
|
return Leg(
|
|
163
161
|
instrument_type=self.instrument_type,
|
|
@@ -254,6 +252,8 @@ class NewOrder(TastytradeData):
|
|
|
254
252
|
preflight_id: str | None = None
|
|
255
253
|
rules: OrderRule | None = None
|
|
256
254
|
advanced_instructions: AdvancedInstructions | None = None
|
|
255
|
+
#: External identifier for the order, used to track orders across systems
|
|
256
|
+
external_identifier: str | None = None
|
|
257
257
|
|
|
258
258
|
@computed_field # type: ignore[misc]
|
|
259
259
|
@property
|
|
@@ -330,6 +330,8 @@ class PlacedOrder(TastytradeData):
|
|
|
330
330
|
preflight_id: str | int | None = None
|
|
331
331
|
order_rule: OrderRule | None = None
|
|
332
332
|
source: str | None = None
|
|
333
|
+
#: External identifier for the order, used to track orders across systems
|
|
334
|
+
external_identifier: str | None = None
|
|
333
335
|
|
|
334
336
|
@model_validator(mode="before")
|
|
335
337
|
@classmethod
|
tastytrade/session.py
CHANGED
|
@@ -9,8 +9,8 @@ from typing_extensions import Self
|
|
|
9
9
|
|
|
10
10
|
from tastytrade import API_URL, API_VERSION, CERT_URL, logger
|
|
11
11
|
from tastytrade.utils import (
|
|
12
|
-
TZ,
|
|
13
12
|
TastytradeData,
|
|
13
|
+
now_in_new_york,
|
|
14
14
|
validate_and_parse,
|
|
15
15
|
validate_response,
|
|
16
16
|
)
|
|
@@ -274,11 +274,9 @@ class Session:
|
|
|
274
274
|
#: Refresh token for the user
|
|
275
275
|
self.refresh_token = refresh_token
|
|
276
276
|
# The headers to use for API requests
|
|
277
|
-
headers = {
|
|
278
|
-
|
|
279
|
-
"Accept-Version"
|
|
280
|
-
"Content-Type": "application/json",
|
|
281
|
-
}
|
|
277
|
+
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
278
|
+
if not is_test: # not accepted in sandbox
|
|
279
|
+
headers["Accept-Version"] = API_VERSION
|
|
282
280
|
#: httpx client for sync requests
|
|
283
281
|
self.sync_client = Client(
|
|
284
282
|
base_url=(CERT_URL if is_test else API_URL), headers=headers, proxy=proxy
|
|
@@ -288,7 +286,7 @@ class Session:
|
|
|
288
286
|
base_url=self.sync_client.base_url, headers=headers, proxy=proxy
|
|
289
287
|
)
|
|
290
288
|
#: expiration for streamer token
|
|
291
|
-
self.streamer_expiration =
|
|
289
|
+
self.streamer_expiration = now_in_new_york()
|
|
292
290
|
self.refresh()
|
|
293
291
|
|
|
294
292
|
def _streamer_refresh(self) -> None:
|
|
@@ -325,14 +323,14 @@ class Session:
|
|
|
325
323
|
self.session_token = data["access_token"]
|
|
326
324
|
token_lifetime: int = data.get("expires_in", 900)
|
|
327
325
|
#: expiration for session token
|
|
328
|
-
self.session_expiration =
|
|
326
|
+
self.session_expiration = now_in_new_york() + timedelta(seconds=token_lifetime)
|
|
329
327
|
logger.debug(f"Refreshed token, expires in {token_lifetime}ms")
|
|
330
328
|
auth_headers = {"Authorization": f"Bearer {self.session_token}"}
|
|
331
329
|
# update the httpx clients with the new token
|
|
332
330
|
self.sync_client.headers.update(auth_headers)
|
|
333
331
|
self.async_client.headers.update(auth_headers)
|
|
334
332
|
# update the streamer token if necessary
|
|
335
|
-
if self.streamer_expiration < self.session_expiration:
|
|
333
|
+
if not self.is_test and self.streamer_expiration < self.session_expiration:
|
|
336
334
|
self._streamer_refresh()
|
|
337
335
|
|
|
338
336
|
async def a_refresh(self) -> None:
|
|
@@ -357,14 +355,14 @@ class Session:
|
|
|
357
355
|
# update the relevant tokens
|
|
358
356
|
self.session_token = data["access_token"]
|
|
359
357
|
token_lifetime: int = data.get("expires_in", 900)
|
|
360
|
-
self.session_expiration =
|
|
358
|
+
self.session_expiration = now_in_new_york() + timedelta(token_lifetime)
|
|
361
359
|
logger.debug(f"Refreshed token, expires in {token_lifetime}ms")
|
|
362
360
|
auth_headers = {"Authorization": f"Bearer {self.session_token}"}
|
|
363
361
|
# update the httpx clients with the new token
|
|
364
362
|
self.sync_client.headers.update(auth_headers)
|
|
365
363
|
self.async_client.headers.update(auth_headers)
|
|
366
364
|
# update the streamer token if necessary
|
|
367
|
-
if self.streamer_expiration < self.session_expiration:
|
|
365
|
+
if not self.is_test and self.streamer_expiration < self.session_expiration:
|
|
368
366
|
# Pull streamer tokens and urls
|
|
369
367
|
data = await self._a_get("/api-quote-tokens")
|
|
370
368
|
# Auth token for dxfeed websocket
|
|
@@ -378,8 +376,6 @@ class Session:
|
|
|
378
376
|
async def a_get_customer(self) -> Customer:
|
|
379
377
|
"""
|
|
380
378
|
Gets the customer dict from the API.
|
|
381
|
-
|
|
382
|
-
:return: a Tastytrade 'Customer' object in JSON format.
|
|
383
379
|
"""
|
|
384
380
|
data = await self._a_get("/customers/me")
|
|
385
381
|
return Customer(**data)
|
|
@@ -387,8 +383,6 @@ class Session:
|
|
|
387
383
|
def get_customer(self) -> Customer:
|
|
388
384
|
"""
|
|
389
385
|
Gets the customer dict from the API.
|
|
390
|
-
|
|
391
|
-
:return: a Tastytrade 'Customer' object in JSON format.
|
|
392
386
|
"""
|
|
393
387
|
data = self._get("/customers/me")
|
|
394
388
|
return Customer(**data)
|
|
@@ -396,8 +390,6 @@ class Session:
|
|
|
396
390
|
async def a_validate(self) -> bool:
|
|
397
391
|
"""
|
|
398
392
|
Validates the current session by sending a request to the API.
|
|
399
|
-
|
|
400
|
-
:return: True if the session is valid and False otherwise.
|
|
401
393
|
"""
|
|
402
394
|
response = await self.async_client.post("/sessions/validate")
|
|
403
395
|
return response.status_code // 100 == 2
|
|
@@ -405,8 +397,6 @@ class Session:
|
|
|
405
397
|
def validate(self) -> bool:
|
|
406
398
|
"""
|
|
407
399
|
Validates the current session by sending a request to the API.
|
|
408
|
-
|
|
409
|
-
:return: True if the session is valid and False otherwise.
|
|
410
400
|
"""
|
|
411
401
|
response = self.sync_client.post("/sessions/validate")
|
|
412
402
|
return response.status_code // 100 == 2
|
|
@@ -422,6 +412,7 @@ class Session:
|
|
|
422
412
|
del attrs["sync_client"]
|
|
423
413
|
attrs["session_expiration"] = self.session_expiration.strftime(_fmt)
|
|
424
414
|
attrs["streamer_expiration"] = self.streamer_expiration.strftime(_fmt)
|
|
415
|
+
attrs["headers"] = dict(self.sync_client.headers.copy())
|
|
425
416
|
return json.dumps(attrs)
|
|
426
417
|
|
|
427
418
|
@classmethod
|
|
@@ -429,17 +420,11 @@ class Session:
|
|
|
429
420
|
"""
|
|
430
421
|
Create a new Session object from a serialized string.
|
|
431
422
|
"""
|
|
432
|
-
deserialized = json.loads(serialized)
|
|
423
|
+
deserialized: dict[str, Any] = json.loads(serialized)
|
|
424
|
+
headers = deserialized.pop("headers")
|
|
433
425
|
self = cls.__new__(cls)
|
|
434
426
|
self.__dict__ = deserialized
|
|
435
427
|
base_url = CERT_URL if self.is_test else API_URL
|
|
436
|
-
headers = {
|
|
437
|
-
"Accept": "application/json",
|
|
438
|
-
"Content-Type": "application/json",
|
|
439
|
-
"Authorization": self.session_token
|
|
440
|
-
if "user" in deserialized
|
|
441
|
-
else f"Bearer {self.session_token}",
|
|
442
|
-
}
|
|
443
428
|
self.session_expiration = datetime.strptime(
|
|
444
429
|
deserialized["session_expiration"], _fmt
|
|
445
430
|
)
|
tastytrade/utils.py
CHANGED
|
@@ -29,8 +29,6 @@ class PriceEffect(str, Enum):
|
|
|
29
29
|
def now_in_new_york() -> datetime:
|
|
30
30
|
"""
|
|
31
31
|
Gets the current time in the New York timezone.
|
|
32
|
-
|
|
33
|
-
:return: current time as datetime
|
|
34
32
|
"""
|
|
35
33
|
return datetime.now(TZ)
|
|
36
34
|
|
|
@@ -38,8 +36,6 @@ def now_in_new_york() -> datetime:
|
|
|
38
36
|
def today_in_new_york() -> date:
|
|
39
37
|
"""
|
|
40
38
|
Gets the current date in the New York timezone.
|
|
41
|
-
|
|
42
|
-
:return: current date
|
|
43
39
|
"""
|
|
44
40
|
return now_in_new_york().date()
|
|
45
41
|
|
|
@@ -50,8 +46,6 @@ def is_market_open_on(day: date | None = None) -> bool:
|
|
|
50
46
|
during the given day.
|
|
51
47
|
|
|
52
48
|
:param day: date to check. If not provided defaults to current NY date.
|
|
53
|
-
|
|
54
|
-
:return: whether the market opens on given day
|
|
55
49
|
"""
|
|
56
50
|
day = day or today_in_new_york()
|
|
57
51
|
date_range = NYSE.valid_days(day, day)
|
|
@@ -64,8 +58,6 @@ def get_third_friday(day: date | None = None) -> date:
|
|
|
64
58
|
or the monthly expiration associated with today's month.
|
|
65
59
|
|
|
66
60
|
:param day: date to check. If not provided defaults to current NY date.
|
|
67
|
-
|
|
68
|
-
:return: the associated monthly
|
|
69
61
|
"""
|
|
70
62
|
day = (day or today_in_new_york()).replace(day=1) + timedelta(weeks=2)
|
|
71
63
|
while day.weekday() != 4: # Friday
|
|
@@ -76,8 +68,6 @@ def get_third_friday(day: date | None = None) -> date:
|
|
|
76
68
|
def get_tasty_monthly() -> date:
|
|
77
69
|
"""
|
|
78
70
|
Gets the monthly expiration closest to 45 days from the current date.
|
|
79
|
-
|
|
80
|
-
:return: the closest to 45 DTE monthly expiration
|
|
81
71
|
"""
|
|
82
72
|
day = today_in_new_york()
|
|
83
73
|
exp1 = get_third_friday(day + timedelta(weeks=4))
|
|
@@ -101,8 +91,6 @@ def get_future_fx_monthly(day: date | None = None) -> date:
|
|
|
101
91
|
Wednesday.
|
|
102
92
|
|
|
103
93
|
:param day: date to check. If not provided defaults to current NY date.
|
|
104
|
-
|
|
105
|
-
:return: the associated monthly
|
|
106
94
|
"""
|
|
107
95
|
day = (day or today_in_new_york()).replace(day=1) + timedelta(weeks=1)
|
|
108
96
|
while day.weekday() != 2: # Wednesday
|
|
@@ -120,8 +108,6 @@ def get_future_treasury_monthly(day: date | None = None) -> date:
|
|
|
120
108
|
business day prior.
|
|
121
109
|
|
|
122
110
|
:param day: date to check. If not provided defaults to current NY date.
|
|
123
|
-
|
|
124
|
-
:return: the associated monthly
|
|
125
111
|
"""
|
|
126
112
|
day = day or today_in_new_york()
|
|
127
113
|
last_day = _get_last_day_of_month(day)
|
|
@@ -143,8 +129,6 @@ def get_future_metal_monthly(day: date | None = None) -> date:
|
|
|
143
129
|
which case they expire on the prior business day.
|
|
144
130
|
|
|
145
131
|
:param day: date to check. If not provided defaults to current NY date.
|
|
146
|
-
|
|
147
|
-
:return: the associated monthly
|
|
148
132
|
"""
|
|
149
133
|
day = day or today_in_new_york()
|
|
150
134
|
last_day = _get_last_day_of_month(day)
|
|
@@ -164,8 +148,6 @@ def get_future_grain_monthly(day: date | None = None) -> date:
|
|
|
164
148
|
least 2 business days, the last business day of the month.
|
|
165
149
|
|
|
166
150
|
:param day: date to check. If not provided defaults to current NY date.
|
|
167
|
-
|
|
168
|
-
:return: the associated monthly
|
|
169
151
|
"""
|
|
170
152
|
day = day or today_in_new_york()
|
|
171
153
|
last_day = _get_last_day_of_month(day)
|
|
@@ -185,8 +167,6 @@ def get_future_oil_monthly(day: date | None = None) -> date:
|
|
|
185
167
|
they expire 7 business days prior to the 25th day of the month.
|
|
186
168
|
|
|
187
169
|
:param day: date to check. If not provided defaults to current NY date.
|
|
188
|
-
|
|
189
|
-
:return: the associated monthly
|
|
190
170
|
"""
|
|
191
171
|
last_day = (day or today_in_new_york()).replace(day=25)
|
|
192
172
|
first_day = last_day.replace(day=1)
|
|
@@ -201,8 +181,6 @@ def get_future_index_monthly(day: date | None = None) -> date:
|
|
|
201
181
|
month.
|
|
202
182
|
|
|
203
183
|
:param day: date to check. If not provided defaults to current NY date.
|
|
204
|
-
|
|
205
|
-
:return: the associated monthly
|
|
206
184
|
"""
|
|
207
185
|
day = day or today_in_new_york()
|
|
208
186
|
last_day = _get_last_day_of_month(day)
|
|
@@ -224,8 +202,6 @@ def _dasherize(s: str) -> str:
|
|
|
224
202
|
Converts a string from snake case to dasherized.
|
|
225
203
|
|
|
226
204
|
:param s: string to convert
|
|
227
|
-
|
|
228
|
-
:return: dasherized string
|
|
229
205
|
"""
|
|
230
206
|
return s.replace("_", "-")
|
|
231
207
|
|
|
@@ -1,20 +1,19 @@
|
|
|
1
|
-
tastytrade/__init__.py,sha256=
|
|
2
|
-
tastytrade/account.py,sha256=
|
|
3
|
-
tastytrade/instruments.py,sha256=
|
|
1
|
+
tastytrade/__init__.py,sha256=xHWei-s28krH-rZAarRbWbUNypkAe5cSh7frMFlr4mQ,531
|
|
2
|
+
tastytrade/account.py,sha256=Jsgq0hfkvMZlNI7c_-J-zLA3yF6KN8P7ozypckPLgPc,56778
|
|
3
|
+
tastytrade/instruments.py,sha256=QZHakLgxsgscZx7ouz1LgIuahlHM2n0AfY9UQBdlEoM,46803
|
|
4
4
|
tastytrade/market_data.py,sha256=iyBpleKvDWfXReo6DuKbXP-gN1Yv8iyvNs9z2x5Gxig,5940
|
|
5
5
|
tastytrade/market_sessions.py,sha256=r0L-4CSzjx2gC3pJS1C0ae9RiahaKWs67Ljng3DwODI,3382
|
|
6
6
|
tastytrade/metrics.py,sha256=LqpUZIR4L2bQwawf_L4gJ3Pjg_S72eURrJpu4ebKtpU,7495
|
|
7
|
-
tastytrade/
|
|
8
|
-
tastytrade/order.py,sha256=Z-3Oa-0JcRXPQhNGUnRJCIWrkimHV4cKQUPC9G4FKIU,14868
|
|
7
|
+
tastytrade/order.py,sha256=BNgiFAjKvE5jYW4IGK-T6lGjb_Tp2fg2CnV-X2Xg8rU,15082
|
|
9
8
|
tastytrade/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
9
|
tastytrade/search.py,sha256=LdoVEhiYNvtolXlf_jAVZUtA2ymUWOvHMhQxJxuVt_A,1529
|
|
11
|
-
tastytrade/session.py,sha256=
|
|
10
|
+
tastytrade/session.py,sha256=ZqjonG0oJRPvLYVoArBRwR3d8OK7xHEYmQ8a1eQroyY,15122
|
|
12
11
|
tastytrade/streamer.py,sha256=7HISXzo3AKloOafMDp5NHSxW19cRcobfrrOUoymt-g4,32609
|
|
13
|
-
tastytrade/utils.py,sha256=
|
|
12
|
+
tastytrade/utils.py,sha256=a4hzwlN-rc7vJ6LBDml36ys0ZPwrhfNlJeFUaORJao0,12302
|
|
14
13
|
tastytrade/watchlists.py,sha256=K9jpgXi9a9dklGl-jCXzpbM0tOd836GSkXa4fySeH3Q,8657
|
|
15
14
|
tastytrade/dxfeed/__init__.py,sha256=GmC0aKtiUjs7aqbX7PeqMaROxqalwzHOnJOMJn8TaZk,458
|
|
16
15
|
tastytrade/dxfeed/candle.py,sha256=j9nuWftzOT_qGDTZNNfFIABZp_n_5Gi7OFm5KPK2dnc,1757
|
|
17
|
-
tastytrade/dxfeed/event.py,sha256=
|
|
16
|
+
tastytrade/dxfeed/event.py,sha256=C7ANFpblgk4MfNivvat25ox0jHmm6cCzQGYiXlsi81s,6021
|
|
18
17
|
tastytrade/dxfeed/greeks.py,sha256=Q9cGrXswtWGrti8eN6Owhvs9vC5YcdtW5mJrN92_4eA,1071
|
|
19
18
|
tastytrade/dxfeed/profile.py,sha256=sj-HCN7qrQvP61jEOzsEpwDsl5M_VMR_KekyRyjdsXY,1896
|
|
20
19
|
tastytrade/dxfeed/quote.py,sha256=MLRe9NQZI1DbY5ZgYnyUh52Z9824uep9ZYgngbRhmbk,944
|
|
@@ -23,7 +22,7 @@ tastytrade/dxfeed/theoprice.py,sha256=L5aH--F_6xLZCSYZ4APpzlihbW0-cYEwRdeGVI-aNa
|
|
|
23
22
|
tastytrade/dxfeed/timeandsale.py,sha256=QuMFoccq8x3c2y6s3DnwBNIVTrLS6OPqV6GmCNoXQEQ,1903
|
|
24
23
|
tastytrade/dxfeed/trade.py,sha256=qNo4oKb7iq0Opoq3FCBEUUcGGF6udda1bD0eKQVty_0,1402
|
|
25
24
|
tastytrade/dxfeed/underlying.py,sha256=YYqJNlmrlt6Kpg0F6voQ18g60obXiYTVlroXirBWPR8,1226
|
|
26
|
-
tastytrade-11.0.
|
|
27
|
-
tastytrade-11.0.
|
|
28
|
-
tastytrade-11.0.
|
|
29
|
-
tastytrade-11.0.
|
|
25
|
+
tastytrade-11.0.5.dist-info/METADATA,sha256=ZNfRbZ3yg5nbwV2dHXN_0I9kgcB3Uys1w297e-MGzvM,10904
|
|
26
|
+
tastytrade-11.0.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
27
|
+
tastytrade-11.0.5.dist-info/licenses/LICENSE,sha256=enBkMN4OsfLt6Z_AsrGC7u5dAJkCEODnoN7BwMCzSfc,1072
|
|
28
|
+
tastytrade-11.0.5.dist-info/RECORD,,
|
tastytrade/oauth.py
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
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()
|
|
File without changes
|