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.
Files changed (86) hide show
  1. {tastytrade-10.2.2 → tastytrade-10.3.0}/.github/workflows/python-app.yml +2 -0
  2. {tastytrade-10.2.2 → tastytrade-10.3.0}/PKG-INFO +1 -1
  3. tastytrade-10.3.0/docs/sessions.rst +73 -0
  4. {tastytrade-10.2.2 → tastytrade-10.3.0}/pyproject.toml +3 -0
  5. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/__init__.py +3 -2
  6. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/account.py +9 -8
  7. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/backtest.py +2 -2
  8. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/market_data.py +1 -0
  9. tastytrade-10.3.0/tastytrade/oauth.py +129 -0
  10. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/session.py +62 -62
  11. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/streamer.py +38 -36
  12. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/utils.py +25 -38
  13. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_backtest.py +4 -1
  14. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_instruments.py +12 -2
  15. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_session.py +24 -1
  16. tastytrade-10.2.2/docs/sessions.rst +0 -32
  17. {tastytrade-10.2.2 → tastytrade-10.3.0}/.github/CONTRIBUTING.md +0 -0
  18. {tastytrade-10.2.2 → tastytrade-10.3.0}/.github/FUNDING.yml +0 -0
  19. {tastytrade-10.2.2 → tastytrade-10.3.0}/.github/pull_request_template.md +0 -0
  20. {tastytrade-10.2.2 → tastytrade-10.3.0}/.github/workflows/python-publish-test.yml +0 -0
  21. {tastytrade-10.2.2 → tastytrade-10.3.0}/.github/workflows/python-publish.yml +0 -0
  22. {tastytrade-10.2.2 → tastytrade-10.3.0}/.gitignore +0 -0
  23. {tastytrade-10.2.2 → tastytrade-10.3.0}/.python-version +0 -0
  24. {tastytrade-10.2.2 → tastytrade-10.3.0}/.readthedocs.yaml +0 -0
  25. {tastytrade-10.2.2 → tastytrade-10.3.0}/LICENSE +0 -0
  26. {tastytrade-10.2.2 → tastytrade-10.3.0}/Makefile +0 -0
  27. {tastytrade-10.2.2 → tastytrade-10.3.0}/README.md +0 -0
  28. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/Makefile +0 -0
  29. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/account-streamer.rst +0 -0
  30. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/accounts.rst +0 -0
  31. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/account.rst +0 -0
  32. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/backtesting.rst +0 -0
  33. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/dxfeed.rst +0 -0
  34. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/instruments.rst +0 -0
  35. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/market-data.rst +0 -0
  36. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/market-sessions.rst +0 -0
  37. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/metrics.rst +0 -0
  38. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/order.rst +0 -0
  39. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/search.rst +0 -0
  40. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/session.rst +0 -0
  41. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/streamer.rst +0 -0
  42. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/utils.rst +0 -0
  43. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/api/watchlists.rst +0 -0
  44. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/backtest.rst +0 -0
  45. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/conf.py +0 -0
  46. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/data-streamer.rst +0 -0
  47. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/img/netliq.png +0 -0
  48. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/index.rst +0 -0
  49. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/installation.rst +0 -0
  50. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/instruments.rst +0 -0
  51. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/make.bat +0 -0
  52. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/market-data.rst +0 -0
  53. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/market-sessions.rst +0 -0
  54. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/orders.rst +0 -0
  55. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/sync-async.rst +0 -0
  56. {tastytrade-10.2.2 → tastytrade-10.3.0}/docs/watchlists.rst +0 -0
  57. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/__init__.py +0 -0
  58. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/candle.py +0 -0
  59. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/event.py +0 -0
  60. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/greeks.py +0 -0
  61. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/profile.py +0 -0
  62. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/quote.py +0 -0
  63. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/summary.py +0 -0
  64. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/theoprice.py +0 -0
  65. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/timeandsale.py +0 -0
  66. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/trade.py +0 -0
  67. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/dxfeed/underlying.py +0 -0
  68. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/instruments.py +0 -0
  69. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/market_sessions.py +0 -0
  70. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/metrics.py +0 -0
  71. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/order.py +0 -0
  72. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/py.typed +0 -0
  73. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/search.py +0 -0
  74. {tastytrade-10.2.2 → tastytrade-10.3.0}/tastytrade/watchlists.py +0 -0
  75. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/__init__.py +0 -0
  76. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/conftest.py +0 -0
  77. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_account.py +0 -0
  78. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_dxfeed.py +0 -0
  79. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_market_data.py +0 -0
  80. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_market_sessions.py +0 -0
  81. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_metrics.py +0 -0
  82. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_search.py +0 -0
  83. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_streamer.py +0 -0
  84. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_utils.py +0 -0
  85. {tastytrade-10.2.2 → tastytrade-10.3.0}/tests/test_watchlists.py +0 -0
  86. {tastytrade-10.2.2 → tastytrade-10.3.0}/uv.lock +0 -0
@@ -37,3 +37,5 @@ jobs:
37
37
  TT_USERNAME_SANDBOX: ${{ secrets.TT_USERNAME_SANDBOX }}
38
38
  TT_PASSWORD_SANDBOX: ${{ secrets.TT_PASSWORD_SANDBOX }}
39
39
  TT_ACCOUNT: ${{ secrets.TT_ACCOUNT }}
40
+ TT_REFRESH: ${{ secrets.TT_REFRESH }}
41
+ TT_SECRET: ${{ secrets.TT_SECRET }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tastytrade
3
- Version: 10.2.2
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
@@ -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))
@@ -77,6 +77,9 @@ where = ["tastytrade"]
77
77
  [tool.ruff.lint]
78
78
  select = ["E", "F", "I"]
79
79
 
80
+ [tool.coverage.run]
81
+ omit = ["tastytrade/oauth.py"]
82
+
80
83
  [tool.pyright]
81
84
  pythonVersion = "3.9"
82
85
  include = ["tastytrade", "tests"]
@@ -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.2"
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
- 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]:
@@ -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
 
@@ -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(
@@ -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 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,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
- response = self.sync_client.post(
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
- # update the relevant tokens
602
+ #: The session token used to authenticate requests
592
603
  self.session_token = data["access_token"]
593
- token_lifetime = data.get("expires_in", 900) * 1000
594
- 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)
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
- response = await self.async_client.post(
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) * 1000
618
- 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)
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
- def serialize(self) -> str:
626
- """
627
- Serializes the session to a string, useful for storing
628
- a session for later use.
629
- Could be used with pickle, Redis, etc.
630
- """
631
- attrs = self.__dict__.copy()
632
- del attrs["async_client"]
633
- del attrs["sync_client"]
634
- return json.dumps(attrs)
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 = True
282
- self._connect_task.cancel()
283
- tasks = [self._connect_task]
284
- if self._heartbeat_task and not self._heartbeat_task.done():
285
- self._heartbeat_task.cancel()
286
- tasks.append(self._heartbeat_task)
287
- if self._reconnect_task and not self._reconnect_task.done():
288
- self._reconnect_task.cancel()
289
- tasks.append(self._reconnect_task)
290
- await asyncio.gather(*tasks)
291
- await self._websocket.wait_closed() # type: ignore
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
- if not self._closing:
320
- await self.close()
321
- return
321
+ return await self.close()
322
322
  finally:
323
- 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
+ )
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 = True
519
- self._connect_task.cancel()
520
- tasks = [self._connect_task]
521
- if self._heartbeat_task:
522
- self._heartbeat_task.cancel()
523
- tasks.append(self._heartbeat_task)
524
- if self._reconnect_task is not None and not self._reconnect_task.done():
525
- self._reconnect_task.cancel()
526
- tasks.append(self._reconnect_task)
527
- await asyncio.gather(*tasks)
528
- await self._websocket.wait_closed()
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
- if not self._closing:
611
- await self.close()
612
- return
612
+ return await self.close()
613
613
  finally:
614
- 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
+ )
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": int(start_time.timestamp() * 1000),
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
- 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
@@ -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=[BacktestLeg(), BacktestLeg(side="put")],
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