schwabdev 1.0.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.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.1
2
+ Name: schwabdev
3
+ Version: 1.0.0
4
+ Summary: Schwab API Python Client (unofficial)
5
+ Author: Tyler Bowers
6
+ Author-email: tylerebowers@gmail.com
7
+ License: MIT
8
+ Project-URL: Source, https://github.com/tylerebowers/Schwab-API-Python
9
+ Project-URL: Youtube, https://www.youtube.com/playlist?list=PLs4JLWxBQIxpbvCj__DjAc0RRTlBz-TR8
10
+ Project-URL: PyPI, https://pypi.org/project/schwabdev/
11
+ Keywords: python,schwab,api,client,finance,trading,stocks,equities,options,forex,futures
12
+ Classifier: Topic :: Office/Business :: Financial :: Investment
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Natural Language :: English
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Schwab-API-Python
22
+ This is an unofficial python program to access the Schwab api.
23
+ You will need a Schwab developer account [here](https://beta-developer.schwab.com/).
24
+ Join the [Discord group](https://discord.gg/m7SSjr9rs9).
25
+ Also found on [PyPI](https://pypi.org/project/schwabdev/), install via `pip3 install schwabdev`
26
+
27
+
28
+ ## Quick setup
29
+ 1. Create a new Schwab individual developer app with callback url "https://127.0.0.1" (case sensitive) and wait until the status is "Ready for use", note that "Approved - Pending" will not work.
30
+ 2. Enable TOS (Thinkorswim) for your Schwab account, it is needed for orders and other api calls.
31
+ 3. Python version 3.11 or higher is required.
32
+ 4. `pip3 install schwabdev requests websockets tk` (tkinter/tk may need to be installed differently)
33
+ 5. Import the package `import schwabdev`
34
+ 6. Create a client `client = schwabdev.Client('Your app key', 'Your app secret')`
35
+ 7. Examples on how to use the client are in `tests/api_demo.py`
36
+
37
+ ## What can this program do?
38
+ - Authenticate and access the api
39
+ - Functions for all api functions (examples in `tests/api_demo.py`)
40
+ - Auto "access token" updates (`client.update_tokens_auto()`)
41
+ - Stream real-time data with customizable response handler (examples in `tests/stream_demo.py`)
42
+ ### TBD
43
+ - Automatic refresh token updates. (Waiting for Schwab implementation)
44
+
45
+ ## Notes
46
+
47
+ The schwabdev folder contains code for main operations:
48
+ - `api.py` contains functions relating to api calls, requests, and automatic token checker threads.
49
+ - `stream.py` contains functions for streaming data from websockets.
50
+ - `terminal.py` contains a program for making additional terminal windows and printing to the terminal with color.
51
+
52
+ ## License (MIT)
53
+
54
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
55
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
56
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
57
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
58
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
59
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
60
+ SOFTWARE.
@@ -0,0 +1,40 @@
1
+ # Schwab-API-Python
2
+ This is an unofficial python program to access the Schwab api.
3
+ You will need a Schwab developer account [here](https://beta-developer.schwab.com/).
4
+ Join the [Discord group](https://discord.gg/m7SSjr9rs9).
5
+ Also found on [PyPI](https://pypi.org/project/schwabdev/), install via `pip3 install schwabdev`
6
+
7
+
8
+ ## Quick setup
9
+ 1. Create a new Schwab individual developer app with callback url "https://127.0.0.1" (case sensitive) and wait until the status is "Ready for use", note that "Approved - Pending" will not work.
10
+ 2. Enable TOS (Thinkorswim) for your Schwab account, it is needed for orders and other api calls.
11
+ 3. Python version 3.11 or higher is required.
12
+ 4. `pip3 install schwabdev requests websockets tk` (tkinter/tk may need to be installed differently)
13
+ 5. Import the package `import schwabdev`
14
+ 6. Create a client `client = schwabdev.Client('Your app key', 'Your app secret')`
15
+ 7. Examples on how to use the client are in `tests/api_demo.py`
16
+
17
+ ## What can this program do?
18
+ - Authenticate and access the api
19
+ - Functions for all api functions (examples in `tests/api_demo.py`)
20
+ - Auto "access token" updates (`client.update_tokens_auto()`)
21
+ - Stream real-time data with customizable response handler (examples in `tests/stream_demo.py`)
22
+ ### TBD
23
+ - Automatic refresh token updates. (Waiting for Schwab implementation)
24
+
25
+ ## Notes
26
+
27
+ The schwabdev folder contains code for main operations:
28
+ - `api.py` contains functions relating to api calls, requests, and automatic token checker threads.
29
+ - `stream.py` contains functions for streaming data from websockets.
30
+ - `terminal.py` contains a program for making additional terminal windows and printing to the terminal with color.
31
+
32
+ ## License (MIT)
33
+
34
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
35
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
36
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
37
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
38
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
39
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
40
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ from .api import Client
@@ -0,0 +1,393 @@
1
+ import json
2
+ import base64
3
+ import requests
4
+ import threading
5
+ import urllib.parse
6
+ from .stream import Stream
7
+ from datetime import datetime
8
+ from schwabdev import terminal
9
+
10
+
11
+ class Client:
12
+
13
+ def __init__(self, app_key, app_secret, callback_url="https://127.0.0.1", tokens_file="tokens.json"):
14
+ if app_key is None or app_secret is None or callback_url is None or tokens_file is None:
15
+ raise Exception("app_key, app_secret, callback_url, and tokens_file cannot be None.")
16
+ elif len(app_key) != 32 or len(app_secret) != 16:
17
+ raise Exception("App key or app secret invalid length.")
18
+
19
+ self._app_key = app_key
20
+ self._app_secret = app_secret
21
+ self._callback_url = callback_url
22
+ self.access_token = None
23
+ self.refresh_token = None
24
+ self.id_token = None
25
+ self._access_token_issued = None # datetime of access token issue
26
+ self._refresh_token_issued = None # datetime of refresh token issue
27
+ self._access_token_timeout = 1800 # in seconds (from schwab)
28
+ self._refresh_token_timeout = 7 # in days (from schwab)
29
+ self._tokens_file = tokens_file # path to tokens file
30
+ self.stream = Stream(self)
31
+
32
+ # Try to load tokens from the tokens file
33
+ at_issued, rt_issued, token_dictionary = self._read_tokens_file()
34
+ if None not in [at_issued, rt_issued, token_dictionary]:
35
+ # show user when tokens were last updated and when they will expire
36
+ self.access_token = token_dictionary.get("access_token")
37
+ self.refresh_token = token_dictionary.get("refresh_token")
38
+ self.id_token = token_dictionary.get("id_token")
39
+ self._access_token_issued = at_issued
40
+ self._refresh_token_issued = rt_issued
41
+ terminal.color_print.info(self._access_token_issued.strftime(
42
+ "Access token last updated: %Y-%m-%d %H:%M:%S") + f" (expires in {self._access_token_timeout - (datetime.now() - self._access_token_issued).seconds} seconds)")
43
+ terminal.color_print.info(self._refresh_token_issued.strftime(
44
+ "Refresh token last updated: %Y-%m-%d %H:%M:%S") + f" (expires in {self._refresh_token_timeout - (datetime.now() - self._refresh_token_issued).days} days)")
45
+ # check if tokens need to be updated and update if needed
46
+ self.update_tokens()
47
+ else:
48
+ # The tokens file doesn't exist, so create it.
49
+ terminal.color_print.warning(f"Token file does not exist or invalid formatting, creating \"{str(tokens_file)}\"")
50
+ open(self._tokens_file, 'w').close()
51
+ # Tokens must be updated.
52
+ self._update_refresh_token()
53
+
54
+ # get account numbers & hashes, this doubles as a checker to make sure that the appKey and appSecret are valid and that the app is ready for use
55
+ resp = self.account_linked()
56
+ if resp.ok:
57
+ d = resp.json()
58
+ terminal.color_print.info(f"Linked Accounts: {d}")
59
+ else: # app might not be "Ready For Use"
60
+ terminal.color_print.error("Could not get linked accounts.")
61
+ terminal.color_print.error("Please make sure that your app status is \"Ready For Use\" and that the app key and app secret are valid.")
62
+ terminal.color_print.error(resp.json())
63
+ resp.close()
64
+
65
+ terminal.color_print.info("Initialization Complete")
66
+
67
+ def update_tokens(self):
68
+ if (datetime.now() - self._refresh_token_issued).days >= (
69
+ self._refresh_token_timeout - 1): # check if we need to update refresh (and access) token
70
+ for i in range(5): terminal.color_print.user("The refresh token has expired, please update!")
71
+ self._update_refresh_token()
72
+ elif ((datetime.now() - self._access_token_issued).days >= 1) or (
73
+ (datetime.now() - self._access_token_issued).seconds > (
74
+ self._access_token_timeout - 60)): # check if we need to update access token
75
+ terminal.color_print.info("The access token has expired, updating automatically.")
76
+ self._update_access_token()
77
+ # else: terminal.color_print.info("Token check passed")
78
+
79
+ def update_tokens_auto(self):
80
+ def checker():
81
+ import time
82
+ while True:
83
+ self.update_tokens()
84
+ time.sleep(60)
85
+
86
+ threading.Thread(target=checker, daemon=True).start()
87
+
88
+ # "refresh" the access token using the refresh token
89
+ def _update_access_token(self):
90
+ # get the token dictionary (we will need to rewrite the file)
91
+ access_token_time_old, refresh_token_issued, token_dictionary_old = self._read_tokens_file()
92
+ # get new tokens
93
+ response = self._post_oauth_token('refresh_token', token_dictionary_old.get("refresh_token"))
94
+ if response.ok:
95
+ # get and update to the new access token
96
+ self._access_token_issued = datetime.now()
97
+ self._refresh_token_issued = refresh_token_issued
98
+ new_td = response.json()
99
+ self.access_token = new_td.get("access_token")
100
+ self.refresh_token = new_td.get("refresh_token")
101
+ self.id_token = new_td.get("id_token")
102
+ self._write_tokens_file(self._access_token_issued, refresh_token_issued, new_td)
103
+ # show user that we have updated the access token
104
+ terminal.color_print.info(f"Access token updated: {self._access_token_issued}")
105
+ else:
106
+ terminal.color_print.error("Could not get new access token.")
107
+
108
+ # get new access and refresh tokens using authorization code.
109
+ def _update_refresh_token(self):
110
+ import webbrowser
111
+ # get authorization code (requires user to authorize)
112
+ terminal.color_print.user("Please authorize this program to access your schwab account.")
113
+ auth_url = f'https://api.schwabapi.com/v1/oauth/authorize?client_id={self._app_key}&redirect_uri={self._callback_url}'
114
+ terminal.color_print.user(f"Click to authenticate: {auth_url}")
115
+ terminal.color_print.user("Opening browser..")
116
+ webbrowser.open(auth_url)
117
+ response_url = terminal.color_print.input(
118
+ "After authorizing, wait for it to load (<1min) and paste the WHOLE url here: ")
119
+ code = f"{response_url[response_url.index('code=') + 5:response_url.index('%40')]}@" # session = responseURL[responseURL.index("session=")+8:]
120
+ # get new access and refresh tokens
121
+ response = self._post_oauth_token('authorization_code', code)
122
+ if response.ok:
123
+ # update token file and variables
124
+ self._access_token_issued = self._refresh_token_issued = datetime.now()
125
+ new_td = response.json()
126
+ self.access_token = new_td.get("access_token")
127
+ self.refresh_token = new_td.get("refresh_token")
128
+ self.id_token = new_td.get("id_token")
129
+ self._write_tokens_file(self._access_token_issued, self._refresh_token_issued, new_td)
130
+ terminal.color_print.info("Refresh and Access tokens updated")
131
+ else:
132
+ terminal.color_print.error("Could not get new refresh and access tokens.")
133
+ terminal.color_print.error(
134
+ "Please make sure that your app status is \"Ready For Use\" and that the app key and app secret are valid.")
135
+
136
+ def _post_oauth_token(self, grant_type, code):
137
+ headers = {
138
+ 'Authorization': f'Basic {base64.b64encode(bytes(f"{self._app_key}:{self._app_secret}", "utf-8")).decode("utf-8")}',
139
+ 'Content-Type': 'application/x-www-form-urlencoded'}
140
+ if grant_type == 'authorization_code': # gets access and refresh tokens using authorization code
141
+ data = {'grant_type': 'authorization_code', 'code': code,
142
+ 'redirect_uri': self._callback_url}
143
+ elif grant_type == 'refresh_token': # refreshes the access token
144
+ data = {'grant_type': 'refresh_token', 'refresh_token': code}
145
+ else:
146
+ terminal.color_print.error("Invalid grant type")
147
+ return None
148
+ return requests.post('https://api.schwabapi.com/v1/oauth/token', headers=headers, data=data)
149
+
150
+ def _write_tokens_file(self, atIssued, rtIssued, tokenDictionary):
151
+ # update tokens file
152
+ try:
153
+ with open(self._tokens_file, 'w') as f:
154
+ toWrite = {"access_token_issued": atIssued.isoformat(), "refresh_token_issued": rtIssued.isoformat(),
155
+ "token_dictionary": tokenDictionary}
156
+ json.dump(toWrite, f, ensure_ascii=False, indent=4)
157
+ f.flush()
158
+ except Exception as e:
159
+ terminal.color_print.error(e)
160
+
161
+
162
+ def _read_tokens_file(self):
163
+ try:
164
+ with open(self._tokens_file, 'r') as f:
165
+ d = json.load(f)
166
+ return datetime.fromisoformat(d.get("access_token_issued")), datetime.fromisoformat(d.get("refresh_token_issued")), d.get("token_dictionary")
167
+ except Exception as e:
168
+ terminal.color_print.error(e)
169
+ return None, None, None
170
+
171
+ def _params_parser(self, params):
172
+ for key in list(params.keys()):
173
+ if params[key] is None: del params[key]
174
+ return params
175
+
176
+ def _time_convert(self, dt=None, form="8601"):
177
+ if dt is None:
178
+ return None
179
+ elif dt is str:
180
+ return dt
181
+ elif form == "8601": # assume datetime object from here on
182
+ return f'{dt.isoformat()[:-3]}Z'
183
+ elif form == "epoch":
184
+ return int(dt.timestamp())
185
+ elif form == "epoch_ms":
186
+ return int(dt.timestamp() * 1000)
187
+ elif form == "YYYY-MM-DD":
188
+ return dt.strftime("%Y-%M-%d")
189
+ else:
190
+ return dt
191
+
192
+ def _format_list(self, l):
193
+ if l is None:
194
+ return None
195
+ elif type(l) is list:
196
+ return ",".join(l)
197
+ else:
198
+ return l
199
+
200
+ _base_api_url = "https://api.schwabapi.com"
201
+
202
+ """
203
+ Accounts and Trading Production
204
+ """
205
+
206
+ # Get account numbers and account hashes for linked accounts
207
+ def account_linked(self):
208
+ return requests.get(f'{self._base_api_url}/trader/v1/accounts/accountNumbers',
209
+ headers={'Authorization': f'Bearer {self.access_token}'},
210
+ timeout=2)
211
+
212
+ # Get account details for all linked accounts, details such as balance, positions, buying power, etc.
213
+ def account_details_all(self, fields=None):
214
+ return requests.get(f'{self._base_api_url}/trader/v1/accounts/',
215
+ headers={'Authorization': f'Bearer {self.access_token}'},
216
+ params=self._params_parser({'fields': fields}),
217
+ timeout=2)
218
+
219
+ # Get account details for one linked account, uses default account.
220
+ def account_details(self, accountHash, fields=None):
221
+ return requests.get(f'{self._base_api_url}/trader/v1/accounts/{accountHash}',
222
+ headers={'Authorization': f'Bearer {self.access_token}'},
223
+ params=self._params_parser({'fields': fields}),
224
+ timeout=2)
225
+
226
+ # Get all orders for one linked account, uses default account.
227
+ def account_orders(self, accountHash, maxResults, fromEnteredTime, toEnteredTime, status=None):
228
+ return requests.get(f'{self._base_api_url}/trader/v1/accounts/{accountHash}/orders',
229
+ headers={"Accept": "application/json", 'Authorization': f'Bearer {self.access_token}'},
230
+ params=self._params_parser(
231
+ {'maxResults': maxResults, 'fromEnteredTime': self._time_convert(fromEnteredTime, "8601"),
232
+ 'toEnteredTime': self._time_convert(toEnteredTime, "8601"), 'status': status}),
233
+ timeout=2)
234
+
235
+ # place an order for one linked account (uses default account)
236
+ def order_place(self, accountHash, order):
237
+ return requests.post(f'{self._base_api_url}/trader/v1/accounts/{accountHash}/orders',
238
+ headers={"Accept": "application/json", 'Authorization': f'Bearer {self.access_token}',
239
+ "Content-Type": "application/json"},
240
+ json=order,
241
+ timeout=2)
242
+
243
+ # get order details using order id (uses default account)
244
+ def order_details(self, accountHash, orderId):
245
+ return requests.get(f'{self._base_api_url}/trader/v1/accounts/{accountHash}/orders/{orderId}',
246
+ headers={'Authorization': f'Bearer {self.access_token}'},
247
+ timeout=2)
248
+
249
+ # cancel order using order id (uses default account)
250
+ def order_cancel(self, accountHash, orderId):
251
+ return requests.delete(f'{self._base_api_url}/trader/v1/accounts/{accountHash}/orders/{orderId}',
252
+ headers={'Authorization': f'Bearer {self.access_token}'},
253
+ timeout=2)
254
+
255
+ # replace order using order id (uses default account)
256
+ def order_replace(self, accountHash, orderId, order):
257
+ return requests.put(f'{self._base_api_url}/trader/v1/accounts/{accountHash}/orders/{orderId}',
258
+ headers={"Accept": "application/json", 'Authorization': f'Bearer {self.access_token}',
259
+ "Content-Type": "application/json"},
260
+ json=order,
261
+ timeout=2)
262
+
263
+ # get all orders across all linked accounts
264
+ def account_orders_all(self, maxResults, fromEnteredTime, toEnteredTime, status=None):
265
+ return requests.get(f'{self._base_api_url}/trader/v1/orders',
266
+ headers={"Accept": "application/json", 'Authorization': f'Bearer {self.access_token}'},
267
+ params=self._params_parser(
268
+ {'maxResults': maxResults, 'fromEnteredTime': self._time_convert(fromEnteredTime, "8601"),
269
+ 'toEnteredTime': self._time_convert(toEnteredTime, "8601"), 'status': status}),
270
+ timeout=2)
271
+
272
+ """ #COMING SOON (waiting on Schwab)
273
+ # /accounts/{accountHash}/previewOrder
274
+ def order_preview(accountHash, orderObject):
275
+
276
+ return requests.post(f'{self._base_api_url}/trader/v1/accounts/{accountHash}/previewOrder',
277
+ headers={'Authorization': f'Bearer {self.access_token}',
278
+ "Content-Type": "application.json"}, data=orderObject)
279
+
280
+ """
281
+
282
+ # get all transactions (has maximums) for one linked account (uses default account)
283
+ def transactions(self, accountHash, startDate, endDate, types, symbol=None):
284
+ return requests.get(f'{self._base_api_url}/trader/v1/accounts/{accountHash}/transactions',
285
+ headers={'Authorization': f'Bearer {self.access_token}'},
286
+ params=self._params_parser(
287
+ {'accountNumber': accountHash, 'startDate': self._time_convert(startDate, "8601"),
288
+ 'endDate': self._time_convert(endDate, "8601"), 'symbol': symbol, 'types': types}),
289
+ timeout=2)
290
+
291
+ # get transaction details using transaction id (uses default account)
292
+ def transaction_details(self, accountHash, transactionId):
293
+ return requests.get(f'{self._base_api_url}/trader/v1/accounts/{accountHash}/transactions/{transactionId}',
294
+ headers={'Authorization': f'Bearer {self.access_token}'},
295
+ params={'accountNumber': accountHash, 'transactionId': transactionId},
296
+ timeout=2)
297
+
298
+ # get user preferences, includes streaming info
299
+ def preferences(self):
300
+ return requests.get(f'{self._base_api_url}/trader/v1/userPreference',
301
+ headers={'Authorization': f'Bearer {self.access_token}'},
302
+ timeout=2)
303
+
304
+ """
305
+ Market Data
306
+ """
307
+
308
+ # get quotes for a list of tickers
309
+ def quotes(self, symbols=None, fields=None, indicative=False):
310
+ return requests.get(f'{self._base_api_url}/marketdata/v1/quotes',
311
+ headers={'Authorization': f'Bearer {self.access_token}'},
312
+ params=self._params_parser(
313
+ {'symbols': self._format_list(symbols), 'fields': fields, 'indicative': indicative}),
314
+ timeout=2)
315
+
316
+ # get a single quote for a ticker
317
+ def quote(self, symbol_id, fields=None):
318
+ return requests.get(f'{self._base_api_url}/marketdata/v1/{urllib.parse.quote(symbol_id)}/quotes',
319
+ headers={'Authorization': f'Bearer {self.access_token}'},
320
+ params=self._params_parser({'fields': fields}),
321
+ timeout=2)
322
+
323
+ # get option chains for a ticker
324
+ def option_chains(self, symbol, contractType=None, strikeCount=None, includeUnderlyingQuotes=None, strategy=None,
325
+ interval=None,
326
+ strike=None, range=None, fromDate=None, toDate=None, volatility=None, underlyingPrice=None,
327
+ interestRate=None, daysToExpiration=None, expMonth=None, optionType=None, entitlement=None):
328
+ return requests.get(f'{self._base_api_url}/marketdata/v1/chains',
329
+ headers={'Authorization': f'Bearer {self.access_token}'},
330
+ params=self._params_parser(
331
+ {'symbol': symbol, 'contractType': contractType, 'strikeCount': strikeCount,
332
+ 'includeUnderlyingQuotes': includeUnderlyingQuotes, 'strategy': strategy,
333
+ 'interval': interval, 'strike': strike, 'range': range, 'fromDate': fromDate,
334
+ 'toDate': toDate, 'volatility': volatility, 'underlyingPrice': underlyingPrice,
335
+ 'interestRate': interestRate, 'daysToExpiration': daysToExpiration,
336
+ 'expMonth': expMonth, 'optionType': optionType, 'entitlement': entitlement}),
337
+ timeout=2)
338
+
339
+ # get an option expiration chain for a ticker
340
+ def option_expiration_chain(self, symbol):
341
+ return requests.get(f'{self._base_api_url}/marketdata/v1/expirationchain',
342
+ headers={'Authorization': f'Bearer {self.access_token}'},
343
+ params=self._params_parser({'symbol': symbol}),
344
+ timeout=2)
345
+
346
+ # get price history for a ticker
347
+ def price_history(self, symbol, periodType=None, period=None, frequencyType=None, frequency=None, startDate=None,
348
+ endDate=None, needExtendedHoursData=None, needPreviousClose=None):
349
+ return requests.get(f'{self._base_api_url}/marketdata/v1/pricehistory',
350
+ headers={'Authorization': f'Bearer {self.access_token}'},
351
+ params=self._params_parser({'symbol': symbol, 'periodType': periodType, 'period': period,
352
+ 'frequencyType': frequencyType, 'frequency': frequency,
353
+ 'startDate': self._time_convert(startDate, 'epoch_ms'),
354
+ 'endDate': self._time_convert(endDate, 'epoch_ms'),
355
+ 'needExtendedHoursData': needExtendedHoursData,
356
+ 'needPreviousClose': needPreviousClose}),
357
+ timeout=2)
358
+
359
+ # get movers in a specific index and direction
360
+ def movers(self, symbol, sort=None, frequency=None):
361
+ return requests.get(f'{self._base_api_url}/marketdata/v1/movers/{symbol}',
362
+ headers={'Authorization': f'Bearer {self.access_token}'},
363
+ params=self._params_parser({'sort': sort, 'frequency': frequency}),
364
+ timeout=2)
365
+
366
+ # get market hours for a list of markets
367
+ def market_hours(self, symbols, date=None):
368
+ return requests.get(f'{self._base_api_url}/marketdata/v1/markets',
369
+ headers={'Authorization': f'Bearer {self.access_token}'},
370
+ params=self._params_parser(
371
+ {'markets': symbols, #self._format_list(symbols),
372
+ 'date': self._time_convert(date, 'YYYY-MM-DD')}),
373
+ timeout=2)
374
+
375
+ # get market hours for a single market
376
+ def market_hour(self, market_id, date=None):
377
+ return requests.get(f'{self._base_api_url}/marketdata/v1/markets/{market_id}',
378
+ headers={'Authorization': f'Bearer {self.access_token}'},
379
+ params=self._params_parser({'date': self._time_convert(date, 'YYYY-MM-DD')}),
380
+ timeout=2)
381
+
382
+ # get instruments for a list of symbols
383
+ def instruments(self, symbol, projection):
384
+ return requests.get(f'{self._base_api_url}/marketdata/v1/instruments',
385
+ headers={'Authorization': f'Bearer {self.access_token}'},
386
+ params={'symbol': symbol, 'projection': projection},
387
+ timeout=2)
388
+
389
+ # get instruments for a single cusip
390
+ def instrument_cusip(self, cusip_id):
391
+ return requests.get(f'{self._base_api_url}/marketdata/v1/instruments/{cusip_id}',
392
+ headers={'Authorization': f'Bearer {self.access_token}'},
393
+ timeout=2)
@@ -0,0 +1,304 @@
1
+ """
2
+ This file contains functions to stream data
3
+ Coded by Tyler Bowers
4
+ Github: https://github.com/tylerebowers/Schwab-API-Python
5
+ """
6
+
7
+ import json
8
+ import asyncio
9
+ import threading
10
+ import websockets
11
+ import websockets.exceptions
12
+ from time import sleep
13
+ from datetime import datetime, time
14
+ from schwabdev import terminal
15
+
16
+
17
+ class Stream:
18
+
19
+ def __init__(self, client):
20
+ self._websocket = None
21
+ self._streamer_info = None
22
+ self._start_timestamp = None
23
+ self._terminal = None
24
+ self._request_id = 0 # a counter for the request id
25
+ self._queue = [] # a queue of requests to be sent
26
+ self.active = False
27
+ self.client = client # so we can get streamer info
28
+
29
+ async def _start_streamer(self, receiver_func="default"):
30
+ # get streamer info
31
+ response = self.client.preferences()
32
+ if response.ok:
33
+ self._streamer_info = response.json().get('streamerInfo', None)[0]
34
+ else:
35
+ terminal.color_print.error("Could not get streamerInfo")
36
+
37
+ # specify receiver (what do we do with received data)
38
+ if receiver_func == "default":
39
+ if self._terminal is None:
40
+ self._terminal = terminal.multiTerminal(title="Stream output")
41
+
42
+ def default_receiver(data):
43
+ self._terminal.print(data)
44
+ receiver_func = default_receiver
45
+
46
+ # start the stream
47
+ while True:
48
+ try:
49
+ self._start_timestamp = datetime.now()
50
+ terminal.color_print.info("Connecting to streaming server -> ", end="")
51
+ async with websockets.connect(self._streamer_info.get('streamerSocketUrl'), ping_interval=None) as self._websocket:
52
+ print("Connected.")
53
+ login_payload = self.basic_request(service="ADMIN", command="LOGIN", parameters={"Authorization": self.client.access_token, "SchwabClientChannel": self._streamer_info.get("schwabClientChannel"), "SchwabClientFunctionId": self._streamer_info.get("schwabClientFunctionId")})
54
+ await self._websocket.send(json.dumps(login_payload))
55
+ receiver_func(await self._websocket.recv())
56
+ self.active = True
57
+ # send queued requests
58
+ while self._queue:
59
+ await self._websocket.send(json.dumps({"requests": self._queue.pop(0)}))
60
+ receiver_func(await self._websocket.recv())
61
+ # TODO: resend requests if the stream crashes
62
+ while True:
63
+ receiver_func(await self._websocket.recv())
64
+ except Exception as e:
65
+ self.active = False
66
+ terminal.color_print.error(f"{e}")
67
+ if e is websockets.exceptions.ConnectionClosedOK:
68
+ terminal.color_print.info("Stream has closed.")
69
+ break
70
+ elif e is RuntimeError:
71
+ terminal.color_print.warning("Streaming window has closed.")
72
+ break
73
+ elif (datetime.now() - self._start_timestamp).seconds < 60:
74
+ terminal.color_print.error("Stream not alive for more than 1 minute, exiting...")
75
+ break
76
+ else:
77
+ terminal.color_print.warning("Connection lost to server, reconnecting...")
78
+
79
+ def start(self, receiver="default"):
80
+ def _start_async():
81
+ asyncio.run(self._start_streamer(receiver))
82
+
83
+ threading.Thread(target=_start_async).start()
84
+ sleep(4) # wait for thread/stream to start
85
+
86
+
87
+ def start_automatic(self, after_hours=False, pre_hours=False):
88
+ start = time(9, 30, 0) # market opens at 9:30
89
+ end = time(16, 0, 0) # market closes at 4:00
90
+ if pre_hours:
91
+ start = time(8, 0, 0)
92
+ if after_hours:
93
+ end = time(20, 0, 0)
94
+
95
+ def checker():
96
+
97
+ while True:
98
+ in_hours = (start <= datetime.now().time() <= end) and (0 <= datetime.now().weekday() <= 4)
99
+ if in_hours and not self.active:
100
+ self.start()
101
+ elif not in_hours and self.active:
102
+ terminal.color_print.info("Stopping Stream.")
103
+ self.stop()
104
+ sleep(60)
105
+
106
+ threading.Thread(target=checker).start()
107
+
108
+ if not start <= datetime.now().time() <= end:
109
+ terminal.color_print.info("Stream was started outside of active hours and will launch when in hours.")
110
+
111
+
112
+ def send(self, requests):
113
+ async def _send(toSend):
114
+ await self._websocket.send(toSend)
115
+ if type(requests) is not list:
116
+ requests = [requests]
117
+ if self.active:
118
+ toSend = json.dumps({"requests": requests})
119
+ asyncio.run(_send(toSend))
120
+ else:
121
+ terminal.color_print.warning("Stream is not active, request queued.")
122
+ self._queue.append(requests)
123
+
124
+ # TODO: Fix this (wont properly close)
125
+ def stop(self):
126
+ self._request_id += 1
127
+ self.send(self.basic_request(service="ADMIN", command="LOGOUT"))
128
+ self.active = False
129
+
130
+ def basic_request(self, service, command, parameters=None):
131
+ if self._streamer_info is None:
132
+ response = self.client.preferences()
133
+ if response.ok:
134
+ self._streamer_info = response.json().get('streamerInfo', None)[0]
135
+
136
+ if self._streamer_info is not None:
137
+ request = {"service": service.upper(),
138
+ "command": command.upper(),
139
+ "requestid": self._request_id,
140
+ "SchwabClientCustomerId": self._streamer_info.get("schwabClientCustomerId"),
141
+ "SchwabClientCorrelId": self._streamer_info.get("schwabClientCorrelId")}
142
+ if parameters is not None: request["parameters"] = parameters
143
+ self._request_id += 1
144
+ return request
145
+ else:
146
+ terminal.color_print.error("Could not get streamerInfo")
147
+ return None
148
+
149
+ @staticmethod
150
+ def _list_to_string(ls):
151
+ if type(ls) is str: return ls
152
+ elif type(ls) is list: return ",".join(map(str, ls))
153
+
154
+
155
+ # requests that can be sent to the stream
156
+ def chart_equity(self, keys, fields, command="SUBS"):
157
+ return self.basic_request("CHART_EQUITY", command, parameters={"keys": Stream._list_to_string(keys), "fields": Stream._list_to_string(fields)})
158
+
159
+ def chart_futures(self, keys, fields, command="SUBS"):
160
+ return self.basic_request("CHART_FUTURES", command, parameters={"keys": Stream._list_to_string(keys), "fields": Stream._list_to_string(fields)})
161
+
162
+ def level_one_quote(self, keys, fields, command="SUBS"): # Service not available or temporary down.
163
+ return self.basic_request("QUOTE", command, parameters={"keys": Stream._list_to_string(keys), "fields": Stream._list_to_string(fields)})
164
+
165
+ def level_one_option(self, keys, fields, command="SUBS"):
166
+ return self.basic_request("OPTION", command, parameters={"keys": Stream._list_to_string(keys), "fields": Stream._list_to_string(fields)})
167
+
168
+ def level_one_futures(self, keys, fields, command="SUBS"):
169
+ return self.basic_request("LEVELONE_FUTURES", command, parameters={"keys": Stream._list_to_string(keys), "fields": Stream._list_to_string(fields)})
170
+
171
+ def level_one_forex(self, keys, fields, command="SUBS"):
172
+ return self.basic_request("LEVELONE_FOREX", command, parameters={"keys": Stream._list_to_string(keys), "fields": Stream._list_to_string(fields)})
173
+
174
+ def level_one_futures_options(self, keys, fields, command="SUBS"):
175
+ return self.basic_request("LEVELONE_FUTURES_OPTIONS", command, parameters={"keys": Stream._list_to_string(keys), "fields": Stream._list_to_string(fields)})
176
+
177
+
178
+ """
179
+
180
+ class account:
181
+ @staticmethod
182
+ def activity(keys, fields, command="SUBS"):
183
+ return Stream.request(command, "ACCT_ACTIVITY", keys, fields)
184
+
185
+
186
+ class actives:
187
+ @staticmethod
188
+ def nasdaq(keys, fields, command="SUBS"):
189
+ return Stream.request(command, "ACTIVES_NASDAQ", keys, fields)
190
+
191
+ @staticmethod
192
+ def nyse(keys, fields, command="SUBS"):
193
+ return Stream.request(command, "ACTIVES_NYSE", keys, fields)
194
+
195
+ @staticmethod
196
+ def otcbb(keys, fields, command="SUBS"):
197
+ return Stream.request(command, "ACTIVES_OTCBB", keys, fields)
198
+
199
+ @staticmethod
200
+ def options(keys, fields, command="SUBS"):
201
+ return Stream.request(command, "ACTIVES_OPTIONS", keys, fields)
202
+
203
+
204
+
205
+ class book:
206
+ @staticmethod
207
+ def forex(keys, fields, command="SUBS"):
208
+ return Stream.request(command, "FOREX_BOOK", keys, fields)
209
+
210
+ @staticmethod
211
+ def futures(keys, fields, command="SUBS"):
212
+ return Stream.request(command, "FUTURES_BOOK", keys, fields)
213
+
214
+ @staticmethod
215
+ def listed(keys, fields, command="SUBS"):
216
+ return Stream.request(command, "LISTED_BOOK", keys, fields)
217
+
218
+ @staticmethod
219
+ def nasdaq(keys, fields, command="SUBS"):
220
+ return Stream.request(command, "NASDAQ_BOOK", keys, fields)
221
+
222
+ @staticmethod
223
+ def options(keys, fields, command="SUBS"):
224
+ return Stream.request(command, "OPTIONS_BOOK", keys, fields)
225
+
226
+ @staticmethod
227
+ def futures_options(keys, fields, command="SUBS"):
228
+ return Stream.request(command, "FUTURES_OPTIONS_BOOK", keys, fields)
229
+
230
+
231
+ class levelTwo:
232
+ @staticmethod
233
+ def _NA():
234
+ print("Not Available")
235
+
236
+
237
+ class news:
238
+ @staticmethod
239
+ def headline(keys, fields, command="SUBS"):
240
+ return Stream.request(command, "NEWS_HEADLINE", keys, fields)
241
+
242
+ @staticmethod
243
+ def headlineList(keys, fields, command="SUBS"):
244
+ return Stream.request(command, "NEWS_HEADLINELIST", keys, fields)
245
+
246
+ @staticmethod
247
+ def headlineStory(keys, fields, command="SUBS"):
248
+ return Stream.request(command, "NEWS_STORY", keys, fields)
249
+
250
+
251
+ class timeSale:
252
+ @staticmethod
253
+ def equity(keys, fields, command="SUBS"):
254
+ return Stream.request(command, "TIMESALE_EQUITY", keys, fields)
255
+
256
+ @staticmethod
257
+ def forex(keys, fields, command="SUBS"):
258
+ return Stream.request(command, "TIMESALE_FOREX", keys, fields)
259
+
260
+ @staticmethod
261
+ def futures(keys, fields, command="SUBS"):
262
+ return Stream.request(command, "TIMESALE_FUTURES", keys, fields)
263
+
264
+ @staticmethod
265
+ def options(keys, fields, command="SUBS"):
266
+ return Stream.request(command, "TIMESALE_OPTIONS", keys, fields)
267
+
268
+ """
269
+
270
+ """
271
+
272
+ def _streamResponseHandler(streamOut):
273
+ try:
274
+ parentDict = json.loads(streamOut)
275
+ for key in parentDict.keys():
276
+ match key:
277
+ case "notify":
278
+ self._terminal.print(
279
+ f"[Heartbeat]: {Stream.epochMSToDate(parentDict['notify'][0]['heartbeat'])}")
280
+ case "response":
281
+ for resp in parentDict.get('response'):
282
+ self._terminal.print(f"[Response]: {resp}")
283
+ case "snapshot":
284
+ for snap in parentDict.get('snapshot'):
285
+ self._terminal.print(f"[Snapshot]: {snap}")
286
+ case "data":
287
+ for data in parentDict.get("data"):
288
+ if data.get('service').upper() in universe.streamFieldAliases:
289
+ service = data.get("service")
290
+ timestamp = data.get("timestamp")
291
+ for symbolData in data.get("content"):
292
+ tempSnapshot = database.Snapshot(service, symbolData.get("key"), timestamp, symbolData)
293
+ if universe.preferences.usingDatabase:
294
+ database.DBAddSnapshot(tempSnapshot) # add to database
295
+ if universe.preferences.usingDataframes:
296
+ database.DFAddSnapshot(tempSnapshot) # add to dataframes
297
+ self._terminal.print(
298
+ f"[Data]: {tempSnapshot.toPrettyString()}") # to stream output
299
+ case _:
300
+ self._terminal.print(f"[Unknown Response]: {streamOut}")
301
+ except Exception as e:
302
+ self._terminal.print(f"[ERROR]: There was an error in decoding the stream response: {streamOut}")
303
+ self._terminal.print(f"[ERROR]: The error was: {e}")
304
+ """
@@ -0,0 +1,89 @@
1
+ """
2
+ This file is used to print colored text and create multiple terminals
3
+ Github: https://github.com/tylerebowers/Schwab-API-Python
4
+ """
5
+
6
+
7
+ class color_print:
8
+ @staticmethod
9
+ def info(string, end="\n"): print(f"\033[92m{'[INFO]: '}\033[00m{string}", end=end)
10
+ @staticmethod
11
+ def warning(string, end="\n"): print(f"\033[93m{'[WARN]: '}\033[00m{string}", end=end)
12
+ @staticmethod
13
+ def error(string, end="\n"): print(f"\033[91m{'[ERROR]: '}\033[00m{string}", end=end)
14
+ @staticmethod
15
+ def user(string, end="\n"): print(f"\033[94m{'[USER]: '}\033[00m{string}", end=end)
16
+ @staticmethod
17
+ def input(string): return input(f"\033[94m{'[INPUT]: '}\033[00m{string}")
18
+
19
+
20
+ from time import sleep
21
+ import tkinter as tk
22
+ from tkinter import ttk
23
+ import threading
24
+
25
+
26
+ class multiTerminal(threading.Thread):
27
+
28
+ def __init__(self, title="Terminal", height=20, width=200, font=("Courier New", "12"), backgroundColor="gray5", textColor="snow", allowClosing=True, ignoreClosedPrints=True):
29
+ #params
30
+ self.title = title
31
+ self.height = height
32
+ self.width = width
33
+ self.font = font
34
+ self.backgroundColor = backgroundColor
35
+ self.textColor = textColor
36
+ self.allowClosing = allowClosing
37
+ self.ignoreClosedPrints = ignoreClosedPrints
38
+ #internal variables
39
+ self._root = None # main window
40
+ self._tb = None # text box
41
+ self.isOpen = False # if the window is open
42
+ self._safeExit = False # if the window is safe to exit
43
+ threading.Thread.__init__(self, daemon=True) # kill child thread on main thread exit
44
+ self.start()
45
+ sleep(0.5) # wait for window to open
46
+
47
+ def close(self):
48
+ if self.isOpen and self._safeExit: # safeExit is True is no operations are being done (i.e. print)
49
+ self.isOpen = False
50
+ self._root.quit()
51
+ self._root.update()
52
+ self._root = None
53
+
54
+ def run(self):
55
+ self._root = tk.Tk()
56
+ if self.allowClosing: self._root.protocol("WM_DELETE_WINDOW", self.close)
57
+ else: self._root.protocol("WM_DELETE_WINDOW", lambda: None)
58
+ self._root.title(self.title)
59
+ self._tb = tk.Text(self._root, height=self.height, width=self.width, wrap="none", font=self.font)
60
+ self._tb.pack(side="left", fill="both", expand=True)
61
+ self._tb.configure(state="disabled", bg=self.backgroundColor, fg=self.textColor)
62
+ sizegrip = ttk.Sizegrip(self._tb)
63
+ sizegrip.configure(cursor="sizing")
64
+ sizegrip.bind("<1>", self._resize_start)
65
+ sizegrip.bind("<B1-Motion>", self._resize_update)
66
+ self.isOpen = True
67
+ self._safeExit = True
68
+ self._root.mainloop()
69
+
70
+ def print(self, toPrint, end="\n"):
71
+ if self._root is None or not self.isOpen:
72
+ if not self.ignoreClosedPrints: return print(f"Terminal \"{self.title}\" is closed")
73
+ #if not self.ignoreClosedPrints: raise Exception(f"Terminal \"{self.title}\" is closed")
74
+ else:
75
+ self._safeExit = False # needed so that we don't kill mainloop while printing
76
+ self._tb.configure(state="normal")
77
+ self._tb.insert("end", f"{toPrint}{end}")
78
+ self._tb.see("end")
79
+ self._tb.configure(state="disabled")
80
+ self._safeExit = True
81
+
82
+ def _resize_start(self, event):
83
+ self._x = event.x
84
+ self._y = event.y
85
+
86
+ def _resize_update(self, event):
87
+ delta_x = event.x - self._x
88
+ delta_y = event.y - self._y
89
+ self._tb.place_configure(width=self._tb.winfo_width() + delta_x, height=self._tb.winfo_height() + delta_y)
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.1
2
+ Name: schwabdev
3
+ Version: 1.0.0
4
+ Summary: Schwab API Python Client (unofficial)
5
+ Author: Tyler Bowers
6
+ Author-email: tylerebowers@gmail.com
7
+ License: MIT
8
+ Project-URL: Source, https://github.com/tylerebowers/Schwab-API-Python
9
+ Project-URL: Youtube, https://www.youtube.com/playlist?list=PLs4JLWxBQIxpbvCj__DjAc0RRTlBz-TR8
10
+ Project-URL: PyPI, https://pypi.org/project/schwabdev/
11
+ Keywords: python,schwab,api,client,finance,trading,stocks,equities,options,forex,futures
12
+ Classifier: Topic :: Office/Business :: Financial :: Investment
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Natural Language :: English
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Schwab-API-Python
22
+ This is an unofficial python program to access the Schwab api.
23
+ You will need a Schwab developer account [here](https://beta-developer.schwab.com/).
24
+ Join the [Discord group](https://discord.gg/m7SSjr9rs9).
25
+ Also found on [PyPI](https://pypi.org/project/schwabdev/), install via `pip3 install schwabdev`
26
+
27
+
28
+ ## Quick setup
29
+ 1. Create a new Schwab individual developer app with callback url "https://127.0.0.1" (case sensitive) and wait until the status is "Ready for use", note that "Approved - Pending" will not work.
30
+ 2. Enable TOS (Thinkorswim) for your Schwab account, it is needed for orders and other api calls.
31
+ 3. Python version 3.11 or higher is required.
32
+ 4. `pip3 install schwabdev requests websockets tk` (tkinter/tk may need to be installed differently)
33
+ 5. Import the package `import schwabdev`
34
+ 6. Create a client `client = schwabdev.Client('Your app key', 'Your app secret')`
35
+ 7. Examples on how to use the client are in `tests/api_demo.py`
36
+
37
+ ## What can this program do?
38
+ - Authenticate and access the api
39
+ - Functions for all api functions (examples in `tests/api_demo.py`)
40
+ - Auto "access token" updates (`client.update_tokens_auto()`)
41
+ - Stream real-time data with customizable response handler (examples in `tests/stream_demo.py`)
42
+ ### TBD
43
+ - Automatic refresh token updates. (Waiting for Schwab implementation)
44
+
45
+ ## Notes
46
+
47
+ The schwabdev folder contains code for main operations:
48
+ - `api.py` contains functions relating to api calls, requests, and automatic token checker threads.
49
+ - `stream.py` contains functions for streaming data from websockets.
50
+ - `terminal.py` contains a program for making additional terminal windows and printing to the terminal with color.
51
+
52
+ ## License (MIT)
53
+
54
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
55
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
56
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
57
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
58
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
59
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
60
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ setup.py
3
+ schwabdev/__init__.py
4
+ schwabdev/api.py
5
+ schwabdev/stream.py
6
+ schwabdev/terminal.py
7
+ schwabdev.egg-info/PKG-INFO
8
+ schwabdev.egg-info/SOURCES.txt
9
+ schwabdev.egg-info/dependency_links.txt
10
+ schwabdev.egg-info/requires.txt
11
+ schwabdev.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ requests
2
+ websockets
@@ -0,0 +1 @@
1
+ schwabdev
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,37 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ VERSION = '1.0.0'
4
+ DESCRIPTION = 'Schwab API Python Client (unofficial)'
5
+ with open('README.md', 'r') as f:
6
+ LONG_DESCRIPTION = f.read()
7
+
8
+ setup(
9
+ name='schwabdev',
10
+ version=VERSION,
11
+ author='Tyler Bowers',
12
+ author_email='tylerebowers@gmail.com',
13
+ license='MIT',
14
+ description=DESCRIPTION,
15
+ long_description=LONG_DESCRIPTION,
16
+ long_description_content_type='text/markdown',
17
+ packages=find_packages(),
18
+ python_requires='>=3.11',
19
+ install_requires=[
20
+ 'requests',
21
+ 'websockets',
22
+ ],
23
+ keywords=['python', 'schwab', 'api', 'client', 'finance', 'trading', 'stocks', 'equities', 'options', 'forex', 'futures'],
24
+ classifiers=[
25
+ 'Topic :: Office/Business :: Financial :: Investment',
26
+ 'License :: OSI Approved :: MIT License',
27
+ 'Programming Language :: Python :: 3',
28
+ 'Operating System :: OS Independent',
29
+ 'Intended Audience :: Developers',
30
+ 'Natural Language :: English',
31
+ ],
32
+ project_urls={
33
+ 'Source': 'https://github.com/tylerebowers/Schwab-API-Python',
34
+ 'Youtube': 'https://www.youtube.com/playlist?list=PLs4JLWxBQIxpbvCj__DjAc0RRTlBz-TR8',
35
+ 'PyPI': 'https://pypi.org/project/schwabdev/'
36
+ }
37
+ )