firstrade 0.0.21__py3-none-any.whl → 0.0.32__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
firstrade/account.py CHANGED
@@ -1,17 +1,67 @@
1
+ import json
1
2
  import os
2
3
  import pickle
3
- import re
4
4
 
5
+ import pyotp
5
6
  import requests
6
- from bs4 import BeautifulSoup
7
7
 
8
8
  from firstrade import urls
9
+ from firstrade.exceptions import (
10
+ AccountResponseError,
11
+ LoginRequestError,
12
+ LoginResponseError,
13
+ )
9
14
 
10
15
 
11
16
  class FTSession:
12
- """Class creating a session for Firstrade."""
17
+ """
18
+ Class creating a session for Firstrade.
13
19
 
14
- def __init__(self, username, password, pin, profile_path=None):
20
+ This class handles the creation and management of a session for logging into the Firstrade platform.
21
+ It supports multi-factor authentication (MFA) and can save session cookies for persistent logins.
22
+
23
+ Attributes:
24
+ username (str): Firstrade login username.
25
+ password (str): Firstrade login password.
26
+ pin (str): Firstrade login pin.
27
+ email (str, optional): Firstrade MFA email.
28
+ phone (str, optional): Firstrade MFA phone number.
29
+ mfa_secret (str, optional): Secret key for generating MFA codes.
30
+ profile_path (str, optional): The path where the user wants to save the cookie pkl file.
31
+ t_token (str, optional): Token used for MFA.
32
+ otp_options (dict, optional): Options for OTP (One-Time Password) if MFA is enabled.
33
+ login_json (dict, optional): JSON response from the login request.
34
+ session (requests.Session): The requests session object used for making HTTP requests.
35
+
36
+ Methods:
37
+ __init__(username, password, pin=None, email=None, phone=None, mfa_secret=None, profile_path=None):
38
+ Initializes a new instance of the FTSession class.
39
+ login():
40
+ Validates and logs into the Firstrade platform.
41
+ login_two(code):
42
+ Finishes the login process to the Firstrade platform. When using email or phone mfa.
43
+ delete_cookies():
44
+ Deletes the session cookies.
45
+ _load_cookies():
46
+ Checks if session cookies were saved and loads them.
47
+ _save_cookies():
48
+ Saves session cookies to a file.
49
+ _mask_email(email):
50
+ Masks the email for use in the API.
51
+ _handle_mfa():
52
+ Handles multi-factor authentication.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ username,
58
+ password,
59
+ pin=None,
60
+ email=None,
61
+ phone=None,
62
+ mfa_secret=None,
63
+ profile_path=None,
64
+ ):
15
65
  """
16
66
  Initializes a new instance of the FTSession class.
17
67
 
@@ -19,76 +69,113 @@ class FTSession:
19
69
  username (str): Firstrade login username.
20
70
  password (str): Firstrade login password.
21
71
  pin (str): Firstrade login pin.
22
- persistent_session (bool, optional): Whether the user wants to save the session cookies.
72
+ email (str, optional): Firstrade MFA email.
73
+ phone (str, optional): Firstrade MFA phone number.
23
74
  profile_path (str, optional): The path where the user wants to save the cookie pkl file.
24
75
  """
25
76
  self.username = username
26
77
  self.password = password
27
78
  self.pin = pin
79
+ self.email = FTSession._mask_email(email) if email is not None else None
80
+ self.phone = phone
81
+ self.mfa_secret = mfa_secret
28
82
  self.profile_path = profile_path
83
+ self.t_token = None
84
+ self.otp_options = None
85
+ self.login_json = None
29
86
  self.session = requests.Session()
30
- self.login()
31
87
 
32
88
  def login(self):
33
- """Method to validate and login to the Firstrade platform."""
34
- headers = urls.session_headers()
35
- cookies = self.load_cookies()
36
- cookies = requests.utils.cookiejar_from_dict(cookies)
37
- self.session.cookies.update(cookies)
38
- response = self.session.get(
39
- url=urls.get_xml(), headers=urls.session_headers(), cookies=cookies
40
- )
41
- if response.status_code != 200:
42
- raise Exception(
43
- "Login failed. Check your credentials or internet connection."
44
- )
45
- if "/cgi-bin/sessionfailed?reason=6" in response.text:
46
- self.session.get(url=urls.login(), headers=headers)
47
- data = {
48
- "redirect": "",
49
- "ft_locale": "en-us",
50
- "login.x": "Log In",
51
- "username": r"" + self.username,
52
- "password": r"" + self.password,
53
- "destination_page": "home",
54
- }
89
+ """
90
+ Validates and logs into the Firstrade platform.
55
91
 
56
- self.session.post(
57
- url=urls.login(),
58
- headers=headers,
59
- cookies=self.session.cookies,
60
- data=data,
61
- )
62
- data = {
63
- "destination_page": "home",
64
- "pin": self.pin,
65
- "pin.x": "++OK++",
66
- "sring": "0",
67
- "pin": self.pin,
68
- }
92
+ This method sets up the session headers, loads cookies if available, and performs the login request.
93
+ It handles multi-factor authentication (MFA) if required.
94
+
95
+ Raises:
96
+ LoginRequestError: If the login request fails with a non-200 status code.
97
+ LoginResponseError: If the login response contains an error message.
98
+ """
99
+ self.session.headers = urls.session_headers()
100
+ ftat = self._load_cookies()
101
+ if ftat != "":
102
+ self.session.headers["ftat"] = ftat
103
+ response = self.session.get(url="https://api3x.firstrade.com/", timeout=10)
104
+ self.session.headers["access-token"] = urls.access_token()
105
+
106
+ data = {
107
+ "username": r"" + self.username,
108
+ "password": r"" + self.password,
109
+ }
69
110
 
70
- self.session.post(
71
- url=urls.pin(), headers=headers, cookies=self.session.cookies, data=data
72
- )
73
- self.save_cookies()
111
+ response = self.session.post(
112
+ url=urls.login(),
113
+ data=data,
114
+ )
115
+ try:
116
+ self.login_json = response.json()
117
+ except json.decoder.JSONDecodeError:
118
+ raise LoginResponseError("Invalid JSON is your account funded?")
74
119
  if (
75
- "/cgi-bin/sessionfailed?reason=6"
76
- in self.session.get(
77
- url=urls.get_xml(), headers=urls.session_headers(), cookies=cookies
78
- ).text
120
+ "mfa" not in self.login_json
121
+ and "ftat" in self.login_json
122
+ and self.login_json["error"] == ""
79
123
  ):
80
- raise Exception("Login failed. Check your credentials.")
124
+ self.session.headers["sid"] = self.login_json["sid"]
125
+ return False
126
+ self.t_token = self.login_json.get("t_token")
127
+ if self.mfa_secret is None:
128
+ self.otp_options = self.login_json.get("otp")
129
+ if response.status_code != 200:
130
+ raise LoginRequestError(response.status_code)
131
+ if self.login_json["error"] != "":
132
+ raise LoginResponseError(self.login_json["error"])
133
+ need_code = self._handle_mfa()
134
+ if self.login_json["error"] != "":
135
+ raise LoginResponseError(self.login_json["error"])
136
+ if need_code:
137
+ return True
138
+ self.session.headers["ftat"] = self.login_json["ftat"]
139
+ self.session.headers["sid"] = self.login_json["sid"]
140
+ self._save_cookies()
141
+ return False
142
+
143
+ def login_two(self, code):
144
+ """Method to finish login to the Firstrade platform."""
145
+ data = {
146
+ "otpCode": code,
147
+ "verificationSid": self.session.headers["sid"],
148
+ "remember_for": "30",
149
+ "t_token": self.t_token,
150
+ }
151
+ response = self.session.post(urls.verify_pin(), data=data)
152
+ self.login_json = response.json()
153
+ if self.login_json["error"] != "":
154
+ raise LoginResponseError(self.login_json["error"])
155
+ self.session.headers["ftat"] = self.login_json["ftat"]
156
+ self.session.headers["sid"] = self.login_json["sid"]
157
+ self._save_cookies()
81
158
 
82
- def load_cookies(self):
159
+ def delete_cookies(self):
160
+ """Deletes the session cookies."""
161
+ if self.profile_path is not None:
162
+ path = os.path.join(self.profile_path, f"ft_cookies{self.username}.pkl")
163
+ else:
164
+ path = f"ft_cookies{self.username}.pkl"
165
+ os.remove(path)
166
+
167
+ def _load_cookies(self):
83
168
  """
84
169
  Checks if session cookies were saved.
85
170
 
86
171
  Returns:
87
- Dict: Dictionary of cookies. Nom Nom
172
+ str: The saved session token.
88
173
  """
89
- cookies = {}
90
- directory = os.path.abspath(self.profile_path) if self.profile_path is not None else "."
91
-
174
+
175
+ ftat = ""
176
+ directory = (
177
+ os.path.abspath(self.profile_path) if self.profile_path is not None else "."
178
+ )
92
179
  if not os.path.exists(directory):
93
180
  os.makedirs(directory)
94
181
 
@@ -96,10 +183,10 @@ class FTSession:
96
183
  if filename.endswith(f"{self.username}.pkl"):
97
184
  filepath = os.path.join(directory, filename)
98
185
  with open(filepath, "rb") as f:
99
- cookies = pickle.load(f)
100
- return cookies
186
+ ftat = pickle.load(f)
187
+ return ftat
101
188
 
102
- def save_cookies(self):
189
+ def _save_cookies(self):
103
190
  """Saves session cookies to a file."""
104
191
  if self.profile_path is not None:
105
192
  directory = os.path.abspath(self.profile_path)
@@ -109,15 +196,76 @@ class FTSession:
109
196
  else:
110
197
  path = f"ft_cookies{self.username}.pkl"
111
198
  with open(path, "wb") as f:
112
- pickle.dump(self.session.cookies.get_dict(), f)
113
-
114
- def delete_cookies(self):
115
- """Deletes the session cookies."""
116
- if self.profile_path is not None:
117
- path = os.path.join(self.profile_path, f"ft_cookies{self.username}.pkl")
118
- else:
119
- path = f"ft_cookies{self.username}.pkl"
120
- os.remove(path)
199
+ ftat = self.session.headers.get("ftat")
200
+ pickle.dump(ftat, f)
201
+
202
+ @staticmethod
203
+ def _mask_email(email):
204
+ """
205
+ Masks the email for use in the API.
206
+
207
+ Args:
208
+ email (str): The email address to be masked.
209
+
210
+ Returns:
211
+ str: The masked email address.
212
+ """
213
+ local, domain = email.split("@")
214
+ masked_local = local[0] + "*" * 4
215
+ domain_name, tld = domain.split(".")
216
+ masked_domain = domain_name[0] + "*" * 4
217
+ return f"{masked_local}@{masked_domain}.{tld}"
218
+
219
+ def _handle_mfa(self):
220
+ """
221
+ Handles multi-factor authentication.
222
+
223
+ This method processes the MFA requirements based on the login response and user-provided details.
224
+
225
+ Raises:
226
+ LoginRequestError: If the MFA request fails with a non-200 status code.
227
+ LoginResponseError: If the MFA response contains an error message.
228
+ """
229
+ if not self.login_json["mfa"] and self.pin is not None:
230
+ data = {
231
+ "pin": self.pin,
232
+ "remember_for": "30",
233
+ "t_token": self.t_token,
234
+ }
235
+ response = self.session.post(urls.verify_pin(), data=data)
236
+ self.login_json = response.json()
237
+ elif not self.login_json["mfa"] and (
238
+ self.email is not None or self.phone is not None
239
+ ):
240
+ for item in self.otp_options:
241
+ if item["channel"] == "sms" and self.phone is not None:
242
+ if self.phone in item["recipientMask"]:
243
+ data = {
244
+ "recipientId": item["recipientId"],
245
+ "t_token": self.t_token,
246
+ }
247
+ elif item["channel"] == "email" and self.email is not None:
248
+ if self.email == item["recipientMask"]:
249
+ data = {
250
+ "recipientId": item["recipientId"],
251
+ "t_token": self.t_token,
252
+ }
253
+ response = self.session.post(urls.request_code(), data=data)
254
+ elif self.login_json["mfa"] and self.mfa_secret is not None:
255
+ mfa_otp = pyotp.TOTP(self.mfa_secret).now()
256
+ data = {
257
+ "mfaCode": mfa_otp,
258
+ "remember_for": "30",
259
+ "t_token": self.t_token,
260
+ }
261
+ response = self.session.post(urls.verify_pin(), data=data)
262
+ self.login_json = response.json()
263
+ if self.login_json["error"] == "":
264
+ if self.pin or self.mfa_secret is not None:
265
+ self.session.headers["sid"] = self.login_json["sid"]
266
+ return False
267
+ self.session.headers["sid"] = self.login_json["verificationSid"]
268
+ return True
121
269
 
122
270
  def __getattr__(self, name):
123
271
  """
@@ -146,74 +294,28 @@ class FTAccountData:
146
294
  self.session = session
147
295
  self.all_accounts = []
148
296
  self.account_numbers = []
149
- self.account_statuses = []
150
- self.account_balances = []
151
- self.securities_held = {}
152
- all_account_info = []
153
- html_string = self.session.get(
154
- url=urls.account_list(),
155
- headers=urls.session_headers(),
156
- cookies=self.session.cookies,
157
- ).text
158
- regex_accounts = re.findall(r"([0-9]+)-", html_string)
159
-
160
- for match in regex_accounts:
161
- self.account_numbers.append(match)
162
-
163
- for account in self.account_numbers:
164
- # reset cookies to base login cookies to run scripts
165
- self.session.cookies.clear()
166
- self.session.cookies.update(self.session.load_cookies())
167
- # set account to get data for
168
- data = {"accountId": account}
169
- self.session.post(
170
- url=urls.account_status(),
171
- headers=urls.session_headers(),
172
- cookies=self.session.cookies,
173
- data=data,
174
- )
175
- # request to get account status data
176
- data = {"req": "get_status"}
177
- account_status = self.session.post(
178
- url=urls.status(),
179
- headers=urls.session_headers(),
180
- cookies=self.session.cookies,
181
- data=data,
182
- ).json()
183
- self.account_statuses.append(account_status["data"])
184
- data = {"page": "bal", "account_id": account}
185
- account_soup = BeautifulSoup(
186
- self.session.post(
187
- url=urls.get_xml(),
188
- headers=urls.session_headers(),
189
- cookies=self.session.cookies,
190
- data=data,
191
- ).text,
192
- "xml",
193
- )
194
- balance = account_soup.find("total_account_value").text
195
- self.account_balances.append(balance)
196
- all_account_info.append(
197
- {
198
- account: {
199
- "Balance": balance,
200
- "Status": {
201
- "primary": account_status["data"]["primary"],
202
- "domestic": account_status["data"]["domestic"],
203
- "joint": account_status["data"]["joint"],
204
- "ira": account_status["data"]["ira"],
205
- "hasMargin": account_status["data"]["hasMargin"],
206
- "opLevel": account_status["data"]["opLevel"],
207
- "p_country": account_status["data"]["p_country"],
208
- "mrgnStatus": account_status["data"]["mrgnStatus"],
209
- "opStatus": account_status["data"]["opStatus"],
210
- "margin_id": account_status["data"]["margin_id"],
211
- },
212
- }
213
- }
214
- )
215
-
216
- self.all_accounts = all_account_info
297
+ self.account_balances = {}
298
+ response = self.session.get(url=urls.user_info())
299
+ self.user_info = response.json()
300
+ response = self.session.get(urls.account_list())
301
+ if response.status_code != 200 or response.json()["error"] != "":
302
+ raise AccountResponseError(response.json()["error"])
303
+ self.all_accounts = response.json()
304
+ for item in self.all_accounts["items"]:
305
+ self.account_numbers.append(item["account"])
306
+ self.account_balances[item["account"]] = item["total_value"]
307
+
308
+ def get_account_balances(self, account):
309
+ """Gets account balances for a given account.
310
+
311
+ Args:
312
+ account (str): Account number of the account you want to get balances for.
313
+
314
+ Returns:
315
+ dict: Dict of the response from the API.
316
+ """
317
+ response = self.session.get(urls.account_balances(account))
318
+ return response.json()
217
319
 
218
320
  def get_positions(self, account):
219
321
  """Gets currently held positions for a given account.
@@ -222,36 +324,64 @@ class FTAccountData:
222
324
  account (str): Account number of the account you want to get positions for.
223
325
 
224
326
  Returns:
225
- self.securities_held {dict}:
226
- Dict of held positions with the pos. ticker as the key.
327
+ dict: Dict of the response from the API.
328
+ """
329
+
330
+ response = self.session.get(urls.account_positions(account))
331
+ return response.json()
332
+
333
+ def get_account_history(self, account, date_range="ytd", custom_range=None):
334
+ """Gets account history for a given account.
335
+
336
+ Args:
337
+ account (str): Account number of the account you want to get history for.
338
+ range (str): The range of the history. Defaults to "ytd".
339
+ Available options are
340
+ ["today", "1w", "1m", "2m", "mtd", "ytd", "ly", "cust"].
341
+ custom_range (str): The custom range of the history.
342
+ Defaults to None. If range is "cust",
343
+ this parameter is required.
344
+ Format: ["YYYY-MM-DD", "YYYY-MM-DD"].
345
+
346
+ Returns:
347
+ dict: Dict of the response from the API.
348
+ """
349
+ if date_range == "cust" and custom_range is None:
350
+ raise ValueError("Custom range is required when date_range is 'cust'.")
351
+ response = self.session.get(
352
+ urls.account_history(account, date_range, custom_range)
353
+ )
354
+ return response.json()
355
+
356
+ def get_orders(self, account):
357
+ """
358
+ Retrieves existing order data for a given account.
359
+
360
+ Args:
361
+ ft_session (FTSession): The session object used for making HTTP requests to Firstrade.
362
+ account (str): Account number of the account to retrieve orders for.
363
+
364
+ Returns:
365
+ list: A list of dictionaries, each containing details about an order.
227
366
  """
367
+
368
+ response = self.session.get(url=urls.order_list(account))
369
+ return response.json()
370
+
371
+ def cancel_order(self, order_id):
372
+ """
373
+ Cancels an existing order.
374
+
375
+ Args:
376
+ order_id (str): The order ID to cancel.
377
+
378
+ Returns:
379
+ dict: A dictionary containing the response data.
380
+ """
381
+
228
382
  data = {
229
- "page": "pos",
230
- "accountId": str(account),
383
+ "order_id": order_id,
231
384
  }
232
- position_soup = BeautifulSoup(
233
- self.session.post(
234
- url=urls.get_xml(),
235
- headers=urls.session_headers(),
236
- data=data,
237
- cookies=self.session.cookies,
238
- ).text,
239
- "xml",
240
- )
241
385
 
242
- tickers = position_soup.find_all("symbol")
243
- quantity = position_soup.find_all("quantity")
244
- price = position_soup.find_all("price")
245
- change = position_soup.find_all("change")
246
- change_percent = position_soup.find_all("changepercent")
247
- vol = position_soup.find_all("vol")
248
- for i, ticker in enumerate(tickers):
249
- ticker = ticker.text
250
- self.securities_held[ticker] = {
251
- "quantity": quantity[i].text,
252
- "price": price[i].text,
253
- "change": change[i].text,
254
- "change_percent": change_percent[i].text,
255
- "vol": vol[i].text,
256
- }
257
- return self.securities_held
386
+ response = self.session.post(url=urls.cancel_order(), data=data)
387
+ return response.json()
@@ -0,0 +1,55 @@
1
+ class QuoteError(Exception):
2
+ """Base class for exceptions in the Quote module."""
3
+
4
+
5
+ class QuoteRequestError(QuoteError):
6
+ """Exception raised for errors in the HTTP request during a Quote."""
7
+
8
+ def __init__(self, status_code, message="Error in HTTP request"):
9
+ self.status_code = status_code
10
+ self.message = f"{message}. HTTP status code: {status_code}"
11
+ super().__init__(self.message)
12
+
13
+
14
+ class QuoteResponseError(QuoteError):
15
+ """Exception raised for errors in the API response."""
16
+
17
+ def __init__(self, symbol, error_message):
18
+ self.symbol = symbol
19
+ self.message = f"Failed to get data for {symbol}. API returned the following error: {error_message}"
20
+ super().__init__(self.message)
21
+
22
+
23
+ class LoginError(Exception):
24
+ """Exception raised for errors in the login process."""
25
+
26
+
27
+ class LoginRequestError(LoginError):
28
+ """Exception raised for errors in the HTTP request during login."""
29
+
30
+ def __init__(self, status_code, message="Error in HTTP request during login"):
31
+ self.status_code = status_code
32
+ self.message = f"{message}. HTTP status code: {status_code}"
33
+ super().__init__(self.message)
34
+
35
+
36
+ class LoginResponseError(LoginError):
37
+ """Exception raised for errors in the API response during login."""
38
+
39
+ def __init__(self, error_message):
40
+ self.message = (
41
+ f"Failed to login. API returned the following error: {error_message}"
42
+ )
43
+ super().__init__(self.message)
44
+
45
+
46
+ class AccountError(Exception):
47
+ """Base class for exceptions in the Account module."""
48
+
49
+
50
+ class AccountResponseError(AccountError):
51
+ """Exception raised for errors in the API response when getting account data."""
52
+
53
+ def __init__(self, error_message):
54
+ self.message = f"Failed to get account data. API returned the following error: {error_message}"
55
+ super().__init__(self.message)