Skip to content

Commit d25eec8

Browse files
authored
Merge branch 'main' into dev
2 parents ce2bd25 + c1951e0 commit d25eec8

File tree

10 files changed

+301
-54
lines changed

10 files changed

+301
-54
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
__pycache__/
33
.env
44
token_data.json
5-
custom
5+
custom/*
6+
tmp/*

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,4 @@ This Python client simplifies interactions with the Schwab API by providing a us
4343
Credit to [Tyler Bowers](https://github.com/tylerebowers) for inspiring this work. This is a blatant rip-off of [his work](https://github.com/tylerebowers/Schwab-API-Python) formatted in a more pythonic way. We started as a fork but felt this would likely end up going in a whole different direction as far as our intended purpose and scope. Thank you, Tyler, for your work. We will continue to steal his work and publish it as our own and hope he does the same if it is ever advantageous for him to return the favor.
4444

4545
## License (MIT)
46-
This software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the authors or copyright holders be responsible for any claim, damages, or other liabilities, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software.
46+
Pythonic-Schwab-API is an unofficial API wrapper. It is in no way endorsed by or affiliated with Charles Schwab or any associated organization. Make sure to read and understand the terms of service of the underlying API before using this package. This software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the authors or copyright holders be responsible for any claim, damages, or other liabilities, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software.

api_client.py

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111

1212

1313
class APIClient:
14-
def __init__(self):
14+
def __init__(self, initials):
15+
self.initials = initials
1516
self.account_numbers = None
16-
self.config = APIConfig
17+
self.config = APIConfig(self.initials)
1718
self.session = requests.Session()
1819
self.setup_logging()
1920
self.token_info = self.load_token()
@@ -23,7 +24,7 @@ def __init__(self):
2324
self.manual_authorization_flow()
2425

2526
def setup_logging(self):
26-
logging.basicConfig(**APIConfig.LOGGING_CONFIG)
27+
logging.basicConfig(**self.config.LOGGING_CONFIG)
2728
self.logger = logging.getLogger(__name__)
2829

2930
def ensure_valid_token(self):
@@ -42,7 +43,7 @@ def ensure_valid_token(self):
4243
def manual_authorization_flow(self):
4344
""" Handle the manual steps required to get the authorization code from the user. """
4445
self.logger.info("Starting manual authorization flow.")
45-
auth_url = f"{APIConfig.API_BASE_URL}/v1/oauth/authorize?client_id={APIConfig.APP_KEY}&redirect_uri={APIConfig.CALLBACK_URL}&response_type=code"
46+
auth_url = f"{self.config.API_BASE_URL}/v1/oauth/authorize?client_id={self.config.APP_KEY}&redirect_uri={self.config.CALLBACK_URL}&response_type=code"
4647
webbrowser.open(auth_url)
4748
self.logger.info(f"Please authorize the application by visiting: {auth_url}")
4849
response_url = ColorPrint.input(
@@ -67,9 +68,8 @@ def post_token_request(self, data):
6768
'Content-Type': 'application/x-www-form-urlencoded'
6869
}
6970
response = self.session.post(f"{self.config.API_BASE_URL}/v1/oauth/token", headers=headers, data=data)
70-
if response.ok:
71+
if response.status_code == 200:
7172
self.save_token(response.json())
72-
self.load_token()
7373
self.logger.info("Tokens successfully updated.")
7474
return True
7575
else:
@@ -86,31 +86,37 @@ def refresh_access_token(self):
8686
if not self.post_token_request(data):
8787
self.logger.error("Failed to refresh access token.")
8888
return False
89-
89+
self.token_info = self.load_token()
9090
return self.validate_token()
9191

9292
def save_token(self, token_data):
9393
""" Save token data securely. """
9494
token_data['expires_at'] = (datetime.now() + timedelta(seconds=token_data['expires_in'])).isoformat()
95-
with open('token_data.json', 'w') as f:
95+
with open(f'schwab_token_data_{self.initials}.json', 'w') as f:
9696
json.dump(token_data, f)
9797
self.logger.info("Token data saved successfully.")
9898

9999
def load_token(self):
100100
""" Load token data. """
101101
try:
102-
with open('token_data.json', 'r') as f:
102+
with open(f'schwab_token_data_{self.initials}.json', 'r') as f:
103103
token_data = json.load(f)
104104
return token_data
105105
except Exception as e:
106106
self.logger.warning(f"Loading token failed: {e}")
107107
return None
108108

109-
def validate_token(self):
110-
""" Validate the current token's validity. """
109+
def validate_token(self, force=False):
110+
""" Validate the current token. """
111+
# print(self.token_info['expires_at'])
112+
# print(datetime.now())
113+
# print(datetime.fromisoformat(self.token_info['expires_at']))
114+
# print(datetime.now() < datetime.fromisoformat(self.token_info['expires_at']))
111115
if self.token_info and datetime.now() < datetime.fromisoformat(self.token_info['expires_at']):
116+
print(f"Token expires in {datetime.fromisoformat(self.token_info['expires_at']) - datetime.now()} seconds")
112117
return True
113-
else:
118+
elif force:
119+
print("Token expired or invalid.")
114120
# get AAPL to validate token
115121
params = {'symbol': 'AAPL'}
116122
response = self.make_request(endpoint=f"{self.config.MARKET_DATA_BASE_URL}/chains", params=params, validating=True)
@@ -146,3 +152,11 @@ def make_request(self, endpoint, method="GET", **kwargs):
146152
response = self.session.request(method, url, headers=headers, **kwargs)
147153
response.raise_for_status()
148154
return response.json()
155+
156+
def get_user_preferences(self):
157+
"""Retrieve user preferences."""
158+
try:
159+
return self.make_request(f'{self.config.TRADER_BASE_URL}/userPreference')
160+
except Exception as e:
161+
self.logger.error(f"Failed to get user preferences: {e}")
162+
return None

color_print.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ def print(message_type, message, end="\n"):
1515
def input(message):
1616
return input(f"{ColorPrint.COLORS['input']}{message}")
1717

18+
@staticmethod
19+
def user_input(message):
20+
return input(f"{ColorPrint.COLORS['user']}{message}")
21+
22+
1823

1924
if __name__ == '__main__':
2025
ColorPrint.print('info', 'This is an informational message')

config.py

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,37 @@
22
from dotenv import load_dotenv
33

44
load_dotenv()
5+
SANDBOX = False
56

67

78
class APIConfig:
8-
API_BASE_URL = "https://api.schwabapi.com"
9-
TRADER_BASE_URL = f"{API_BASE_URL}/trader/v1"
10-
ACCOUNTS_BASE_URL = f"{TRADER_BASE_URL}/accounts"
11-
MARKET_DATA_BASE_URL = f"{API_BASE_URL}/marketdata/v1"
12-
ORDERS_BASE_URL = ACCOUNTS_BASE_URL
13-
REQUEST_TIMEOUT = 30 # Timeout for API requests in seconds
14-
RETRY_STRATEGY = {
15-
'total': 3, # Total number of retries to allow
16-
'backoff_factor': 1 # Factor by which the delay between retries will increase
17-
}
18-
TOKEN_REFRESH_THRESHOLD_SECONDS = 300 # Time in seconds before token expiration to attempt refresh
19-
DEBUG_MODE = False
20-
LOGGING_CONFIG = {
21-
'level': 'INFO',
22-
'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
23-
}
24-
APP_KEY = os.getenv('APP_KEY')
25-
APP_SECRET = os.getenv('APP_SECRET')
26-
CALLBACK_URL = os.getenv('CALLBACK_URL')
9+
def __init__(self, initials):
10+
self.initials = initials
11+
if SANDBOX:
12+
self.API_BASE_URL = "http://localhost:4020"
13+
self.TRADER_BASE_URL = self.API_BASE_URL
14+
self.ACCOUNTS_BASE_URL = f"{self.API_BASE_URL}/accounts"
15+
self.MARKET_DATA_BASE_URL = f"{self.API_BASE_URL}/marketdata"
16+
self.ORDERS_BASE_URL = self.ACCOUNTS_BASE_URL
17+
self.STREAMER_INFO_URL = f"{self.API_BASE_URL}/streamer-info"
18+
else:
19+
self.API_BASE_URL = "https://api.schwabapi.com"
20+
self.TRADER_BASE_URL = f"{self.API_BASE_URL}/trader/v1"
21+
self.ACCOUNTS_BASE_URL = f"{self.TRADER_BASE_URL}/accounts"
22+
self.MARKET_DATA_BASE_URL = f"{self.API_BASE_URL}/marketdata/v1"
23+
self.ORDERS_BASE_URL = self.ACCOUNTS_BASE_URL
24+
self.STREAMER_INFO_URL = f"{self.API_BASE_URL}/streamer-info"
25+
self.REQUEST_TIMEOUT = 30 # Timeout for API requests in seconds
26+
self. RETRY_STRATEGY = {
27+
'total': 3, # Total number of retries to allow
28+
'backoff_factor': 1 # Factor by which the delay between retries will increase
29+
}
30+
self.TOKEN_REFRESH_THRESHOLD_SECONDS = 300 # Time in seconds before token expiration to attempt refresh
31+
self.DEBUG_MODE = False
32+
self.LOGGING_CONFIG = {
33+
'level': 'INFO',
34+
'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
35+
}
36+
self.APP_KEY = os.getenv(f'SCHWAB_APP_KEY_{self.initials}')
37+
self.APP_SECRET = os.getenv(f'SCHWAB_APP_SECRET_{self.initials}')
38+
self.CALLBACK_URL = os.getenv('CALLBACK_URL')

main.py

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,68 @@
1+
import asyncio
12
from datetime import datetime, timedelta
23
from api_client import APIClient
34
from accounts import Accounts
45
from market_data import Quotes, Options, PriceHistory, Movers, MarketHours, Instruments
56
from orders import Orders
7+
from stream_client import StreamClient
8+
from asyncio import get_event_loop
9+
import stream_utilities
10+
11+
12+
async def main_stream():
13+
initials="AB"
14+
client = APIClient(initials=initials) # Initialize the API client
15+
stream_client = StreamClient(client)
16+
await stream_client.start() # Start and connect
17+
18+
while stream_client.active:
19+
# Construct and send a subscription request
20+
request = stream_utilities.basic_request(
21+
"LEVELONE_EQUITIES",
22+
request_id=stream_client.request_id,
23+
command="SUBS",
24+
customer_id=stream_client.streamer_info.get("schwabClientCustomerId"),
25+
correl_id=stream_client.streamer_info.get("schwabClientCorrelId"),
26+
parameters={
27+
"keys": "TSLA,AMZN,AAPL,NFLX,BABA",
28+
"fields": "0,1,2,3,4,5,8,9,12,13,15,24,28,29,30,31,48"
29+
}
30+
)
31+
await stream_client.send(request)
32+
message = await stream_client.receive()
33+
print(f"Received: {message}")
34+
await asyncio.sleep(1) # Delay between messages
35+
36+
stream_client.stop()
637

738

839
def main():
9-
client = APIClient() # Initialize the API client
40+
initials="AB"
41+
client = APIClient(initials=initials) # Initialize the API client
1042
accounts_api = Accounts(client)
1143
orders_api = Orders(client)
1244

1345
# Get account numbers for linked accounts
14-
# print(accounts_api.get_account_numbers()) # working
46+
client.account_numbers = accounts_api.get_account_numbers() # working
47+
print(client.account_numbers)
1548

1649
# Get positions for linked accounts
17-
# print(accounts_api.get_all_accounts()) # working
50+
print(accounts_api.get_all_accounts()) # working
1851

19-
sample_account = client.account_numbers[0]
20-
account_hash = sample_account['accountHash']
52+
sample_account = client.account_numbers[0] # working
53+
print(sample_account)
54+
account_hash = sample_account['hashValue'] # working
55+
print(account_hash)
2156

2257
# Get specific account positions
23-
# print(accounts_api.get_account(fields="positions"))
58+
print(accounts_api.get_account(account_hash=account_hash, fields="positions"))
2459

2560
# Get up to 3000 orders for an account for the past week
26-
print(orders_api.get_orders(3000, datetime.now() - timedelta(days=7), datetime.now()).json())
61+
print(orders_api.get_orders(account_hash=account_hash,
62+
max_results=3000,
63+
from_entered_time=datetime.now() - timedelta(days=7),
64+
to_entered_time=datetime.now())
65+
)
2766

2867
# Example to place an order (commented out for safety)
2968
"""
@@ -46,11 +85,16 @@ def main():
4685
# print(orders_api.get_order('account_hash', order_id).json())
4786

4887
# Get up to 3000 orders for all accounts for the past week
49-
print(orders_api.get_orders(account_hash=account_hash, max_results=3000, from_entered_time=datetime.now() - timedelta(days=7), to_entered_time=datetime.now()))
88+
for account in client.account_numbers:
89+
account_hash = account['hashValue']
90+
print(orders_api.get_orders(account_hash=account_hash, max_results=3000, from_entered_time=datetime.now() - timedelta(days=7), to_entered_time=datetime.now()))
5091

5192
# Get all transactions for an account
52-
print(accounts_api.get_account_transactions('account_hash', datetime.now() - timedelta(days=7), datetime.now(),
53-
"TRADE").json())
93+
print(accounts_api.get_account_transactions(account_hash=account_hash,
94+
start_date=datetime.now() - timedelta(days=7),
95+
end_date=datetime.now(),
96+
types="TRADE")
97+
)
5498

5599
# Market-data-related requests
56100
quotes = Quotes(client)
@@ -69,6 +113,9 @@ def main():
69113
# Get an option expiration chain
70114
print(options.get_chains("AAPL").json())
71115

116+
# Get price history for a symbol
117+
print(price_history.by_symbol("AAPL", period_type="day", period=1, frequency_type="minute", frequency=5).json())
118+
72119
# Get movers for an index
73120
print(movers.get_movers("$DJI").json())
74121

@@ -86,6 +133,8 @@ def main():
86133

87134

88135
if __name__ == '__main__':
89-
print(
90-
"Welcome to the unofficial Schwab API interface!\nGitHub: https://github.com/Patch-Code-Prosperity/Pythonic-Schwab-API")
136+
print("Welcome to the unofficial Schwab API interface!\n"
137+
"GitHub: https://github.com/Patch-Code-Prosperity/Pythonic-Schwab-API")
138+
# loop = get_event_loop()
139+
# loop.run_until_complete(main_stream())
91140
main()

multi_terminal.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def __init__(self, title="Terminal", height=20, width=200, font=("Courier New",
1515
self.textColor = textColor
1616
self.allowClosing = allowClosing
1717
self.ignoreClosedPrints = ignoreClosedPrints
18-
self.isOpen = False
18+
self.is_open = False
1919
self.start()
2020

2121
def run(self):
@@ -25,16 +25,16 @@ def run(self):
2525
self.text_box = tk.Text(self.root, height=self.height, width=self.width, font=self.font,
2626
bg=self.backgroundColor, fg=self.textColor, state='disabled')
2727
self.text_box.pack(side="left", fill="both", expand=True)
28-
self.isOpen = True
28+
self.is_open = True
2929
self.root.mainloop()
3030

3131
def close(self):
32-
if self.isOpen:
33-
self.isOpen = False
32+
if self.is_open:
33+
self.is_open = False
3434
self.root.destroy()
3535

3636
def print(self, text, end="\n"):
37-
if not self.isOpen:
37+
if not self.is_open:
3838
if not self.ignoreClosedPrints:
3939
raise Exception(f"Terminal '{self.title}' is closed.")
4040
return

orders.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
1+
from datetime import datetime, timezone, timedelta
2+
3+
14
class Orders:
25
def __init__(self, client):
36
self.client = client
47
self.base_url = client.config.ORDERS_BASE_URL
58

69
def get_orders(self, account_hash, max_results=100, from_entered_time=None, to_entered_time=None, status=None):
710
"""Retrieve a list of orders for a specified account."""
11+
if from_entered_time is None:
12+
from_entered_time = (datetime.now(timezone.utc) - timedelta(days=364)).isoformat(timespec='seconds')
13+
if to_entered_time is None:
14+
to_entered_time = datetime.now(timezone.utc).isoformat(timespec='seconds')
815
params = {
916
'maxResults': max_results,
10-
'fromEnteredTime': from_entered_time.isoformat() if from_entered_time else None,
11-
'toEnteredTime': to_entered_time.isoformat() if to_entered_time else None,
17+
'fromEnteredTime': from_entered_time,
18+
'toEnteredTime': to_entered_time,
1219
'status': status
1320
}
1421
endpoint = f"{self.base_url}/{account_hash}/orders"
@@ -27,9 +34,9 @@ def get_order(self, account_hash, order_id):
2734
def cancel_order(self, account_hash, order_id):
2835
"""Cancel a specific order."""
2936
endpoint = f"{self.base_url}/{account_hash}/orders/{order_id}"
30-
return self.client.delete(endpoint)
37+
return self.client.make_request(endpoint, method='DELETE')
3138

3239
def replace_order(self, account_hash, order_id, new_order_details):
3340
"""Replace an existing order with new details."""
3441
endpoint = f"{self.base_url}/{account_hash}/orders/{order_id}"
35-
return self.client.put(endpoint, data=new_order_details)
42+
return self.client.make_request(endpoint, method='PUT', data=new_order_details)

0 commit comments

Comments
 (0)