firstrade 0.0.21__py3-none-any.whl → 0.0.30__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,66 @@
1
1
  import os
2
2
  import pickle
3
- import re
4
3
 
4
+ import pyotp
5
5
  import requests
6
- from bs4 import BeautifulSoup
7
6
 
8
7
  from firstrade import urls
8
+ from firstrade.exceptions import (
9
+ AccountResponseError,
10
+ LoginRequestError,
11
+ LoginResponseError,
12
+ )
9
13
 
10
14
 
11
15
  class FTSession:
12
- """Class creating a session for Firstrade."""
16
+ """
17
+ Class creating a session for Firstrade.
13
18
 
14
- def __init__(self, username, password, pin, profile_path=None):
19
+ This class handles the creation and management of a session for logging into the Firstrade platform.
20
+ It supports multi-factor authentication (MFA) and can save session cookies for persistent logins.
21
+
22
+ Attributes:
23
+ username (str): Firstrade login username.
24
+ password (str): Firstrade login password.
25
+ pin (str): Firstrade login pin.
26
+ email (str, optional): Firstrade MFA email.
27
+ phone (str, optional): Firstrade MFA phone number.
28
+ mfa_secret (str, optional): Secret key for generating MFA codes.
29
+ profile_path (str, optional): The path where the user wants to save the cookie pkl file.
30
+ t_token (str, optional): Token used for MFA.
31
+ otp_options (dict, optional): Options for OTP (One-Time Password) if MFA is enabled.
32
+ login_json (dict, optional): JSON response from the login request.
33
+ session (requests.Session): The requests session object used for making HTTP requests.
34
+
35
+ Methods:
36
+ __init__(username, password, pin=None, email=None, phone=None, mfa_secret=None, profile_path=None):
37
+ Initializes a new instance of the FTSession class.
38
+ login():
39
+ Validates and logs into the Firstrade platform.
40
+ login_two(code):
41
+ Finishes the login process to the Firstrade platform. When using email or phone mfa.
42
+ delete_cookies():
43
+ Deletes the session cookies.
44
+ _load_cookies():
45
+ Checks if session cookies were saved and loads them.
46
+ _save_cookies():
47
+ Saves session cookies to a file.
48
+ _mask_email(email):
49
+ Masks the email for use in the API.
50
+ _handle_mfa():
51
+ Handles multi-factor authentication.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ username,
57
+ password,
58
+ pin=None,
59
+ email=None,
60
+ phone=None,
61
+ mfa_secret=None,
62
+ profile_path=None,
63
+ ):
15
64
  """
16
65
  Initializes a new instance of the FTSession class.
17
66
 
@@ -19,76 +68,110 @@ class FTSession:
19
68
  username (str): Firstrade login username.
20
69
  password (str): Firstrade login password.
21
70
  pin (str): Firstrade login pin.
22
- persistent_session (bool, optional): Whether the user wants to save the session cookies.
71
+ email (str, optional): Firstrade MFA email.
72
+ phone (str, optional): Firstrade MFA phone number.
23
73
  profile_path (str, optional): The path where the user wants to save the cookie pkl file.
24
74
  """
25
75
  self.username = username
26
76
  self.password = password
27
77
  self.pin = pin
78
+ self.email = FTSession._mask_email(email) if email is not None else None
79
+ self.phone = phone
80
+ self.mfa_secret = mfa_secret
28
81
  self.profile_path = profile_path
82
+ self.t_token = None
83
+ self.otp_options = None
84
+ self.login_json = None
29
85
  self.session = requests.Session()
30
- self.login()
31
86
 
32
87
  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
- }
88
+ """
89
+ Validates and logs into the Firstrade platform.
55
90
 
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
- }
91
+ This method sets up the session headers, loads cookies if available, and performs the login request.
92
+ It handles multi-factor authentication (MFA) if required.
93
+
94
+ Raises:
95
+ LoginRequestError: If the login request fails with a non-200 status code.
96
+ LoginResponseError: If the login response contains an error message.
97
+ """
98
+ self.session.headers = urls.session_headers()
99
+ ftat = self._load_cookies()
100
+ if ftat != "":
101
+ self.session.headers["ftat"] = ftat
102
+ response = self.session.get(url="https://api3x.firstrade.com/", timeout=10)
103
+ self.session.headers["access-token"] = urls.access_token()
104
+
105
+ data = {
106
+ "username": r"" + self.username,
107
+ "password": r"" + self.password,
108
+ }
69
109
 
70
- self.session.post(
71
- url=urls.pin(), headers=headers, cookies=self.session.cookies, data=data
72
- )
73
- self.save_cookies()
110
+ response = self.session.post(
111
+ url=urls.login(),
112
+ data=data,
113
+ )
114
+ self.login_json = response.json()
74
115
  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
116
+ "mfa" not in self.login_json
117
+ and "ftat" in self.login_json
118
+ and self.login_json["error"] == ""
79
119
  ):
80
- raise Exception("Login failed. Check your credentials.")
120
+ self.session.headers["sid"] = self.login_json["sid"]
121
+ return False
122
+ self.t_token = self.login_json.get("t_token")
123
+ if self.mfa_secret is None:
124
+ self.otp_options = self.login_json.get("otp")
125
+ if response.status_code != 200:
126
+ raise LoginRequestError(response.status_code)
127
+ if self.login_json["error"] != "":
128
+ raise LoginResponseError(self.login_json["error"])
129
+ need_code = self._handle_mfa()
130
+ if self.login_json["error"] != "":
131
+ raise LoginResponseError(self.login_json["error"])
132
+ if need_code:
133
+ return True
134
+ self.session.headers["ftat"] = self.login_json["ftat"]
135
+ self.session.headers["sid"] = self.login_json["sid"]
136
+ self._save_cookies()
137
+ return False
138
+
139
+ def login_two(self, code):
140
+ """Method to finish login to the Firstrade platform."""
141
+ data = {
142
+ "otpCode": code,
143
+ "verificationSid": self.session.headers["sid"],
144
+ "remember_for": "30",
145
+ "t_token": self.t_token,
146
+ }
147
+ response = self.session.post(urls.verify_pin(), data=data)
148
+ self.login_json = response.json()
149
+ if self.login_json["error"] != "":
150
+ raise LoginResponseError(self.login_json["error"])
151
+ self.session.headers["ftat"] = self.login_json["ftat"]
152
+ self.session.headers["sid"] = self.login_json["sid"]
153
+ self._save_cookies()
81
154
 
82
- def load_cookies(self):
155
+ def delete_cookies(self):
156
+ """Deletes the session cookies."""
157
+ if self.profile_path is not None:
158
+ path = os.path.join(self.profile_path, f"ft_cookies{self.username}.pkl")
159
+ else:
160
+ path = f"ft_cookies{self.username}.pkl"
161
+ os.remove(path)
162
+
163
+ def _load_cookies(self):
83
164
  """
84
165
  Checks if session cookies were saved.
85
166
 
86
167
  Returns:
87
- Dict: Dictionary of cookies. Nom Nom
168
+ str: The saved session token.
88
169
  """
89
- cookies = {}
90
- directory = os.path.abspath(self.profile_path) if self.profile_path is not None else "."
91
-
170
+
171
+ ftat = ""
172
+ directory = (
173
+ os.path.abspath(self.profile_path) if self.profile_path is not None else "."
174
+ )
92
175
  if not os.path.exists(directory):
93
176
  os.makedirs(directory)
94
177
 
@@ -96,10 +179,10 @@ class FTSession:
96
179
  if filename.endswith(f"{self.username}.pkl"):
97
180
  filepath = os.path.join(directory, filename)
98
181
  with open(filepath, "rb") as f:
99
- cookies = pickle.load(f)
100
- return cookies
182
+ ftat = pickle.load(f)
183
+ return ftat
101
184
 
102
- def save_cookies(self):
185
+ def _save_cookies(self):
103
186
  """Saves session cookies to a file."""
104
187
  if self.profile_path is not None:
105
188
  directory = os.path.abspath(self.profile_path)
@@ -109,15 +192,76 @@ class FTSession:
109
192
  else:
110
193
  path = f"ft_cookies{self.username}.pkl"
111
194
  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)
195
+ ftat = self.session.headers.get("ftat")
196
+ pickle.dump(ftat, f)
197
+
198
+ @staticmethod
199
+ def _mask_email(email):
200
+ """
201
+ Masks the email for use in the API.
202
+
203
+ Args:
204
+ email (str): The email address to be masked.
205
+
206
+ Returns:
207
+ str: The masked email address.
208
+ """
209
+ local, domain = email.split("@")
210
+ masked_local = local[0] + "*" * 4
211
+ domain_name, tld = domain.split(".")
212
+ masked_domain = domain_name[0] + "*" * 4
213
+ return f"{masked_local}@{masked_domain}.{tld}"
214
+
215
+ def _handle_mfa(self):
216
+ """
217
+ Handles multi-factor authentication.
218
+
219
+ This method processes the MFA requirements based on the login response and user-provided details.
220
+
221
+ Raises:
222
+ LoginRequestError: If the MFA request fails with a non-200 status code.
223
+ LoginResponseError: If the MFA response contains an error message.
224
+ """
225
+ if not self.login_json["mfa"] and self.pin is not None:
226
+ data = {
227
+ "pin": self.pin,
228
+ "remember_for": "30",
229
+ "t_token": self.t_token,
230
+ }
231
+ response = self.session.post(urls.verify_pin(), data=data)
232
+ self.login_json = response.json()
233
+ elif not self.login_json["mfa"] and (
234
+ self.email is not None or self.phone is not None
235
+ ):
236
+ for item in self.otp_options:
237
+ if item["channel"] == "sms" and self.phone is not None:
238
+ if self.phone in item["recipientMask"]:
239
+ data = {
240
+ "recipientId": item["recipientId"],
241
+ "t_token": self.t_token,
242
+ }
243
+ elif item["channel"] == "email" and self.email is not None:
244
+ if self.email == item["recipientMask"]:
245
+ data = {
246
+ "recipientId": item["recipientId"],
247
+ "t_token": self.t_token,
248
+ }
249
+ response = self.session.post(urls.request_code(), data=data)
250
+ elif self.login_json["mfa"] and self.mfa_secret is not None:
251
+ mfa_otp = pyotp.TOTP(self.mfa_secret).now()
252
+ data = {
253
+ "mfaCode": mfa_otp,
254
+ "remember_for": "30",
255
+ "t_token": self.t_token,
256
+ }
257
+ response = self.session.post(urls.verify_pin(), data=data)
258
+ self.login_json = response.json()
259
+ if self.login_json["error"] == "":
260
+ if self.pin or self.mfa_secret is not None:
261
+ self.session.headers["sid"] = self.login_json["sid"]
262
+ return False
263
+ self.session.headers["sid"] = self.login_json["verificationSid"]
264
+ return True
121
265
 
122
266
  def __getattr__(self, name):
123
267
  """
@@ -146,74 +290,28 @@ class FTAccountData:
146
290
  self.session = session
147
291
  self.all_accounts = []
148
292
  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
293
+ self.account_balances = {}
294
+ response = self.session.get(url=urls.user_info())
295
+ self.user_info = response.json()
296
+ response = self.session.get(urls.account_list())
297
+ if response.status_code != 200 or response.json()["error"] != "":
298
+ raise AccountResponseError(response.json()["error"])
299
+ self.all_accounts = response.json()
300
+ for item in self.all_accounts["items"]:
301
+ self.account_numbers.append(item["account"])
302
+ self.account_balances[item["account"]] = item["total_value"]
303
+
304
+ def get_account_balances(self, account):
305
+ """Gets account balances for a given account.
306
+
307
+ Args:
308
+ account (str): Account number of the account you want to get balances for.
309
+
310
+ Returns:
311
+ dict: Dict of the response from the API.
312
+ """
313
+ response = self.session.get(urls.account_balances(account))
314
+ return response.json()
217
315
 
218
316
  def get_positions(self, account):
219
317
  """Gets currently held positions for a given account.
@@ -222,36 +320,53 @@ class FTAccountData:
222
320
  account (str): Account number of the account you want to get positions for.
223
321
 
224
322
  Returns:
225
- self.securities_held {dict}:
226
- Dict of held positions with the pos. ticker as the key.
323
+ dict: Dict of the response from the API.
227
324
  """
325
+
326
+ response = self.session.get(urls.account_positions(account))
327
+ return response.json()
328
+
329
+ def get_account_history(self, account):
330
+ """Gets account history for a given account.
331
+
332
+ Args:
333
+ account (str): Account number of the account you want to get history for.
334
+
335
+ Returns:
336
+ dict: Dict of the response from the API.
337
+ """
338
+ response = self.session.get(urls.account_history(account))
339
+ return response.json()
340
+
341
+ def get_orders(self, account):
342
+ """
343
+ Retrieves existing order data for a given account.
344
+
345
+ Args:
346
+ ft_session (FTSession): The session object used for making HTTP requests to Firstrade.
347
+ account (str): Account number of the account to retrieve orders for.
348
+
349
+ Returns:
350
+ list: A list of dictionaries, each containing details about an order.
351
+ """
352
+
353
+ response = self.session.get(url=urls.order_list(account))
354
+ return response.json()
355
+
356
+ def cancel_order(self, order_id):
357
+ """
358
+ Cancels an existing order.
359
+
360
+ Args:
361
+ order_id (str): The order ID to cancel.
362
+
363
+ Returns:
364
+ dict: A dictionary containing the response data.
365
+ """
366
+
228
367
  data = {
229
- "page": "pos",
230
- "accountId": str(account),
368
+ "order_id": order_id,
231
369
  }
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
370
 
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
371
+ response = self.session.post(url=urls.cancel_order(), data=data)
372
+ return response.json()
@@ -0,0 +1,44 @@
1
+ class QuoteError(Exception):
2
+ """Base class for exceptions in the Quote module."""
3
+ pass
4
+
5
+ class QuoteRequestError(QuoteError):
6
+ """Exception raised for errors in the HTTP request during a Quote."""
7
+ def __init__(self, status_code, message="Error in HTTP request"):
8
+ self.status_code = status_code
9
+ self.message = f"{message}. HTTP status code: {status_code}"
10
+ super().__init__(self.message)
11
+
12
+ class QuoteResponseError(QuoteError):
13
+ """Exception raised for errors in the API response."""
14
+ def __init__(self, symbol, error_message):
15
+ self.symbol = symbol
16
+ self.message = f"Failed to get data for {symbol}. API returned the following error: {error_message}"
17
+ super().__init__(self.message)
18
+
19
+ class LoginError(Exception):
20
+ """Exception raised for errors in the login process."""
21
+ pass
22
+
23
+ class LoginRequestError(LoginError):
24
+ """Exception raised for errors in the HTTP request during login."""
25
+ def __init__(self, status_code, message="Error in HTTP request during login"):
26
+ self.status_code = status_code
27
+ self.message = f"{message}. HTTP status code: {status_code}"
28
+ super().__init__(self.message)
29
+
30
+ class LoginResponseError(LoginError):
31
+ """Exception raised for errors in the API response during login."""
32
+ def __init__(self, error_message):
33
+ self.message = f"Failed to login. API returned the following error: {error_message}"
34
+ super().__init__(self.message)
35
+
36
+ class AccountError(Exception):
37
+ """Base class for exceptions in the Account module."""
38
+ pass
39
+
40
+ class AccountResponseError(AccountError):
41
+ """Exception raised for errors in the API response when getting account data."""
42
+ def __init__(self, error_message):
43
+ self.message = f"Failed to get account data. API returned the following error: {error_message}"
44
+ super().__init__(self.message)