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 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.2.3"
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
- effect: Any = data.get("unsettled-cryptocurrency-fiat-effect")
100
- if effect == PriceEffect.DEBIT.value:
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
- effect: Any = data.get("unsettled-cryptocurrency-fiat-effect")
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: str = "Desc",
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: str = "Desc",
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[str] = None,
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[str] = None,
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["buy", "sell"] = "sell"
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
@@ -103,6 +103,7 @@ class MarketData(TastytradeData):
103
103
  volume: Optional[Decimal] = None
104
104
  year_low_price: Optional[Decimal] = None
105
105
  year_high_price: Optional[Decimal] = None
106
+ open_interest: Optional[Decimal] = None
106
107
 
107
108
 
108
109
  def get_market_data(
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 time
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 = False,
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
- exp = data.get("expires-at")
369
- self.streamer_expiration = (
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
- attrs["user"] = attrs["user"].model_dump()
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
- deserialized["user"] = User(**deserialized["user"])
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
- exp = deserialized.get("streamer_expiration")
517
- self.streamer_expiration = datetime.strptime(exp, _fmt) if exp else None
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
- def _now_ms() -> int:
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
- # update the relevant tokens
602
+ #: The session token used to authenticate requests
596
603
  self.session_token = data["access_token"]
597
- token_lifetime = data.get("expires_in", 900) * 1000
598
- self.expires_at = _now_ms() + token_lifetime
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) * 1000
626
- self.expires_at = _now_ms() + token_lifetime
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
- def serialize(self) -> str:
634
- """
635
- Serializes the session to a string, useful for storing
636
- a session for later use.
637
- Could be used with pickle, Redis, etc.
638
- """
639
- attrs = self.__dict__.copy()
640
- del attrs["async_client"]
641
- del attrs["sync_client"]
642
- return json.dumps(attrs)
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
- asyncio.create_task(self.disconnect_fn(self, *self.disconnect_args))
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
- try:
339
- while True:
340
- item = await self._queues[cls_str].get()
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
- asyncio.create_task(self.disconnect_fn(self, *self.disconnect_args))
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
- try:
648
- while True:
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": int(start_time.timestamp() * 1000),
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
- if not day:
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
- if not day:
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
- if not day:
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
- if not day:
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
- if not day:
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
- if not day:
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
- if not day:
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
- if not day:
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
- json: dict[str, Any] = response.json()
266
- content = json.get("error")
267
- if not content:
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
- error_message = f"{content['code']}: {content['message']}"
270
- errors = content.get("errors")
271
- if errors is not None:
272
- for error in errors:
273
- if "code" in error:
274
- error_message += f"\n{error['code']}: {error['message']}"
275
- else:
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(error_message)
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
- data: Optional[dict[str, Any]] = json.get("data")
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
- effect = data.get(f"{key}-effect")
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tastytrade
3
- Version: 10.2.3
3
+ Version: 10.3.0
4
4
  Summary: An unofficial, sync/async SDK for Tastytrade!
5
5
  Project-URL: Homepage, https://github.com/tastyware/tastytrade
6
6
  Project-URL: Documentation, https://tastyworks-api.rtfd.io
@@ -1,16 +1,17 @@
1
- tastytrade/__init__.py,sha256=C1Ziu6t5GQoRBAP9TW_HtjES_m6HT3cJqZK0hBuOV3s,581
2
- tastytrade/account.py,sha256=zjeS3_snMh7QT21NrjPjJQ5RBPChdgHF3w1lMggdAw8,63578
3
- tastytrade/backtest.py,sha256=-IqzxT44d9rGpdCirEj2Kbcl0AaiBOqi9pELHHW6cY8,7898
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=2NnV337-T7OLznPeBfCSsZmTX1upMnfOkZSVdywzZGg,6047
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=SOl4RLm1JAZTlLM0w8iR94bJPNWejh1MXchcbrIhG-4,21109
12
- tastytrade/streamer.py,sha256=-xLVwjB5ozgATZXfibJQIXaZNbz8sD696MkjoHVR_4A,32983
13
- tastytrade/utils.py,sha256=yUWSEux3XrDTOB184_YdDEuwIufITcnH5eUYXT38ukM,9702
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.2.3.dist-info/METADATA,sha256=0ehiijPEx7V1hDLPuvpJJ8f8vD42eNvOgTqSuiCvS7c,10903
27
- tastytrade-10.2.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
28
- tastytrade-10.2.3.dist-info/licenses/LICENSE,sha256=enBkMN4OsfLt6Z_AsrGC7u5dAJkCEODnoN7BwMCzSfc,1072
29
- tastytrade-10.2.3.dist-info/RECORD,,
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,,