firstrade 0.0.34__py3-none-any.whl → 0.0.37__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 +250 -193
- firstrade/exceptions.py +3 -4
- firstrade/order.py +41 -45
- firstrade/symbols.py +48 -58
- firstrade/urls.py +38 -22
- {firstrade-0.0.34.dist-info → firstrade-0.0.37.dist-info}/METADATA +3 -6
- firstrade-0.0.37.dist-info/RECORD +11 -0
- {firstrade-0.0.34.dist-info → firstrade-0.0.37.dist-info}/WHEEL +1 -1
- firstrade-0.0.34.dist-info/RECORD +0 -11
- {firstrade-0.0.34.dist-info → firstrade-0.0.37.dist-info}/licenses/LICENSE +0 -0
- {firstrade-0.0.34.dist-info → firstrade-0.0.37.dist-info}/top_level.txt +0 -0
firstrade/account.py
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import json
|
|
2
|
-
import
|
|
3
|
-
import pickle
|
|
2
|
+
from pathlib import Path
|
|
4
3
|
|
|
5
4
|
import pyotp
|
|
5
|
+
import logging
|
|
6
6
|
import requests
|
|
7
7
|
|
|
8
8
|
from firstrade import urls
|
|
9
9
|
from firstrade.exceptions import (
|
|
10
10
|
AccountResponseError,
|
|
11
|
+
LoginError,
|
|
11
12
|
LoginRequestError,
|
|
12
13
|
LoginResponseError,
|
|
13
14
|
)
|
|
14
15
|
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
15
17
|
|
|
16
18
|
class FTSession:
|
|
17
|
-
"""
|
|
18
|
-
Class creating a session for Firstrade.
|
|
19
|
+
"""Class creating a session for Firstrade.
|
|
19
20
|
|
|
20
21
|
This class handles the creation and management of a session for logging into the Firstrade platform.
|
|
21
22
|
It supports multi-factor authentication (MFA) and can save session cookies for persistent logins.
|
|
@@ -23,18 +24,19 @@ class FTSession:
|
|
|
23
24
|
Attributes:
|
|
24
25
|
username (str): Firstrade login username.
|
|
25
26
|
password (str): Firstrade login password.
|
|
26
|
-
pin (str): Firstrade login pin.
|
|
27
|
+
pin (str, optional): Firstrade login pin.
|
|
27
28
|
email (str, optional): Firstrade MFA email.
|
|
28
29
|
phone (str, optional): Firstrade MFA phone number.
|
|
29
30
|
mfa_secret (str, optional): Secret key for generating MFA codes.
|
|
30
31
|
profile_path (str, optional): The path where the user wants to save the cookie pkl file.
|
|
32
|
+
debug (bool, optional): Log HTTP requests/responses if true. DO NOT POST YOUR LOGS ONLINE.
|
|
31
33
|
t_token (str, optional): Token used for MFA.
|
|
32
34
|
otp_options (dict, optional): Options for OTP (One-Time Password) if MFA is enabled.
|
|
33
35
|
login_json (dict, optional): JSON response from the login request.
|
|
34
36
|
session (requests.Session): The requests session object used for making HTTP requests.
|
|
35
37
|
|
|
36
38
|
Methods:
|
|
37
|
-
__init__(username, password, pin=None, email=None, phone=None, mfa_secret=None, profile_path=None):
|
|
39
|
+
__init__(username, password, pin=None, email=None, phone=None, mfa_secret=None, profile_path=None, debug=False):
|
|
38
40
|
Initializes a new instance of the FTSession class.
|
|
39
41
|
login():
|
|
40
42
|
Validates and logs into the Firstrade platform.
|
|
@@ -50,44 +52,58 @@ class FTSession:
|
|
|
50
52
|
Masks the email for use in the API.
|
|
51
53
|
_handle_mfa():
|
|
52
54
|
Handles multi-factor authentication.
|
|
55
|
+
_request(method, url, **kwargs):
|
|
56
|
+
HTTP requests wrapper to the API.
|
|
57
|
+
|
|
53
58
|
"""
|
|
54
59
|
|
|
55
60
|
def __init__(
|
|
56
61
|
self,
|
|
57
|
-
username,
|
|
58
|
-
password,
|
|
59
|
-
pin=
|
|
60
|
-
email=
|
|
61
|
-
phone=
|
|
62
|
-
mfa_secret=
|
|
63
|
-
profile_path=None,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
username: str,
|
|
63
|
+
password: str,
|
|
64
|
+
pin: str = "",
|
|
65
|
+
email: str = "",
|
|
66
|
+
phone: str = "",
|
|
67
|
+
mfa_secret: str = "",
|
|
68
|
+
profile_path: str | None = None,
|
|
69
|
+
debug: bool = False
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Initialize a new instance of the FTSession class.
|
|
67
72
|
|
|
68
73
|
Args:
|
|
69
74
|
username (str): Firstrade login username.
|
|
70
75
|
password (str): Firstrade login password.
|
|
71
|
-
pin (str): Firstrade login pin.
|
|
76
|
+
pin (str, optional): Firstrade login pin.
|
|
72
77
|
email (str, optional): Firstrade MFA email.
|
|
73
78
|
phone (str, optional): Firstrade MFA phone number.
|
|
79
|
+
mfa_secret (str, optional): Firstrade MFA secret key to generate TOTP.
|
|
74
80
|
profile_path (str, optional): The path where the user wants to save the cookie pkl file.
|
|
81
|
+
debug (bool, optional): Log HTTP requests/responses if true. DO NOT POST YOUR LOGS ONLINE.
|
|
82
|
+
|
|
75
83
|
"""
|
|
76
|
-
self.username = username
|
|
77
|
-
self.password = password
|
|
78
|
-
self.pin = pin
|
|
79
|
-
self.email = FTSession._mask_email(email) if email
|
|
80
|
-
self.phone = phone
|
|
81
|
-
self.mfa_secret = mfa_secret
|
|
82
|
-
self.profile_path = profile_path
|
|
83
|
-
self.
|
|
84
|
-
self.
|
|
85
|
-
|
|
84
|
+
self.username: str = username
|
|
85
|
+
self.password: str = password
|
|
86
|
+
self.pin: str = pin
|
|
87
|
+
self.email: str = FTSession._mask_email(email) if email else ""
|
|
88
|
+
self.phone: str = phone
|
|
89
|
+
self.mfa_secret: str = mfa_secret
|
|
90
|
+
self.profile_path: str | None = profile_path
|
|
91
|
+
self.debug: bool = debug
|
|
92
|
+
if self.debug:
|
|
93
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
94
|
+
# Enable HTTP connection debug output
|
|
95
|
+
import http.client as http_client
|
|
96
|
+
http_client.HTTPConnection.debuglevel = 1
|
|
97
|
+
# requests logging too
|
|
98
|
+
logging.getLogger("requests.packages.urllib3").setLevel(logging.DEBUG)
|
|
99
|
+
logging.getLogger("requests.packages.urllib3").propagate = True
|
|
100
|
+
self.t_token: str | None = None
|
|
101
|
+
self.otp_options: list[dict[str, str]] | None = None
|
|
102
|
+
self.login_json: dict[str, str] = {}
|
|
86
103
|
self.session = requests.Session()
|
|
87
104
|
|
|
88
|
-
def login(self):
|
|
89
|
-
"""
|
|
90
|
-
Validates and logs into the Firstrade platform.
|
|
105
|
+
def login(self) -> bool:
|
|
106
|
+
"""Validate and log into the Firstrade platform.
|
|
91
107
|
|
|
92
108
|
This method sets up the session headers, loads cookies if available, and performs the login request.
|
|
93
109
|
It handles multi-factor authentication (MFA) if required.
|
|
@@ -95,43 +111,42 @@ class FTSession:
|
|
|
95
111
|
Raises:
|
|
96
112
|
LoginRequestError: If the login request fails with a non-200 status code.
|
|
97
113
|
LoginResponseError: If the login response contains an error message.
|
|
114
|
+
|
|
98
115
|
"""
|
|
99
|
-
self.session.headers
|
|
100
|
-
ftat = self._load_cookies()
|
|
101
|
-
if ftat
|
|
116
|
+
self.session.headers.update(urls.session_headers())
|
|
117
|
+
ftat: str = self._load_cookies()
|
|
118
|
+
if ftat:
|
|
102
119
|
self.session.headers["ftat"] = ftat
|
|
103
|
-
response = self.
|
|
120
|
+
response: requests.Response = self._request("get", url="https://api3x.firstrade.com/", timeout=10)
|
|
104
121
|
self.session.headers["access-token"] = urls.access_token()
|
|
105
122
|
|
|
106
|
-
data = {
|
|
123
|
+
data: dict[str, str] = {
|
|
107
124
|
"username": r"" + self.username,
|
|
108
125
|
"password": r"" + self.password,
|
|
109
126
|
}
|
|
110
127
|
|
|
111
|
-
response = self.
|
|
128
|
+
response: requests.Response = self._request(
|
|
129
|
+
"post",
|
|
112
130
|
url=urls.login(),
|
|
113
131
|
data=data,
|
|
114
132
|
)
|
|
115
133
|
try:
|
|
116
|
-
self.login_json = response.json()
|
|
117
|
-
except json.decoder.JSONDecodeError:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
and "ftat" in self.login_json
|
|
122
|
-
and self.login_json["error"] == ""
|
|
123
|
-
):
|
|
134
|
+
self.login_json: dict[str, str] = response.json()
|
|
135
|
+
except json.decoder.JSONDecodeError as exc:
|
|
136
|
+
error_msg = "Invalid JSON is your account funded?"
|
|
137
|
+
raise LoginResponseError(error_msg) from exc
|
|
138
|
+
if "mfa" not in self.login_json and "ftat" in self.login_json and not self.login_json["error"]:
|
|
124
139
|
self.session.headers["sid"] = self.login_json["sid"]
|
|
125
140
|
return False
|
|
126
|
-
self.t_token = self.login_json.get("t_token")
|
|
127
|
-
if self.mfa_secret
|
|
128
|
-
self.otp_options = self.login_json.get("otp")
|
|
141
|
+
self.t_token: str | None = self.login_json.get("t_token")
|
|
142
|
+
if not self.mfa_secret:
|
|
143
|
+
self.otp_options: str | None = self.login_json.get("otp")
|
|
129
144
|
if response.status_code != 200:
|
|
130
145
|
raise LoginRequestError(response.status_code)
|
|
131
|
-
if self.login_json["error"]
|
|
146
|
+
if self.login_json["error"]:
|
|
132
147
|
raise LoginResponseError(self.login_json["error"])
|
|
133
|
-
need_code = self._handle_mfa()
|
|
134
|
-
if self.login_json["error"]
|
|
148
|
+
need_code: bool | None = self._handle_mfa()
|
|
149
|
+
if self.login_json["error"]:
|
|
135
150
|
raise LoginResponseError(self.login_json["error"])
|
|
136
151
|
if need_code:
|
|
137
152
|
return True
|
|
@@ -140,144 +155,183 @@ class FTSession:
|
|
|
140
155
|
self._save_cookies()
|
|
141
156
|
return False
|
|
142
157
|
|
|
143
|
-
def login_two(self, code):
|
|
144
|
-
"""
|
|
145
|
-
data = {
|
|
158
|
+
def login_two(self, code: str) -> None:
|
|
159
|
+
"""Finish login to the Firstrade platform."""
|
|
160
|
+
data: dict[str, str | None] = {
|
|
146
161
|
"otpCode": code,
|
|
147
162
|
"verificationSid": self.session.headers["sid"],
|
|
148
163
|
"remember_for": "30",
|
|
149
164
|
"t_token": self.t_token,
|
|
150
165
|
}
|
|
151
|
-
response = self.
|
|
152
|
-
self.login_json = response.json()
|
|
153
|
-
if self.login_json["error"]
|
|
166
|
+
response: requests.Response = self._request("post", urls.verify_pin(), data=data)
|
|
167
|
+
self.login_json: dict[str, str] = response.json()
|
|
168
|
+
if not self.login_json["error"]:
|
|
154
169
|
raise LoginResponseError(self.login_json["error"])
|
|
155
170
|
self.session.headers["ftat"] = self.login_json["ftat"]
|
|
156
171
|
self.session.headers["sid"] = self.login_json["sid"]
|
|
157
172
|
self._save_cookies()
|
|
158
173
|
|
|
159
|
-
def delete_cookies(self):
|
|
160
|
-
"""
|
|
161
|
-
if self.profile_path is not None
|
|
162
|
-
|
|
163
|
-
else:
|
|
164
|
-
path = f"ft_cookies{self.username}.pkl"
|
|
165
|
-
os.remove(path)
|
|
174
|
+
def delete_cookies(self) -> None:
|
|
175
|
+
"""Delete the session cookies."""
|
|
176
|
+
path: Path = Path(self.profile_path) / f"ft_cookies{self.username}.json" if self.profile_path is not None else Path(f"ft_cookies{self.username}.json")
|
|
177
|
+
path.unlink()
|
|
166
178
|
|
|
167
|
-
def _load_cookies(self):
|
|
168
|
-
"""
|
|
169
|
-
Checks if session cookies were saved.
|
|
179
|
+
def _load_cookies(self) -> str:
|
|
180
|
+
"""Check if session cookies were saved.
|
|
170
181
|
|
|
171
182
|
Returns:
|
|
172
183
|
str: The saved session token.
|
|
173
|
-
"""
|
|
174
184
|
|
|
185
|
+
"""
|
|
175
186
|
ftat = ""
|
|
176
|
-
directory = (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
filepath = os.path.join(directory, filename)
|
|
185
|
-
with open(filepath, "rb") as f:
|
|
186
|
-
ftat = pickle.load(f)
|
|
187
|
+
directory: Path = Path(self.profile_path) if self.profile_path is not None else Path()
|
|
188
|
+
if not directory.exists():
|
|
189
|
+
directory.mkdir(parents=True)
|
|
190
|
+
|
|
191
|
+
for filepath in directory.iterdir():
|
|
192
|
+
if filepath.name.endswith(f"{self.username}.json"):
|
|
193
|
+
with filepath.open(mode="r") as f:
|
|
194
|
+
ftat: str = json.load(fp=f)
|
|
187
195
|
return ftat
|
|
188
196
|
|
|
189
|
-
def _save_cookies(self):
|
|
190
|
-
"""
|
|
197
|
+
def _save_cookies(self) -> str | None:
|
|
198
|
+
"""Save session cookies to a file."""
|
|
191
199
|
if self.profile_path is not None:
|
|
192
|
-
directory =
|
|
193
|
-
if not
|
|
194
|
-
|
|
195
|
-
path =
|
|
200
|
+
directory = Path(self.profile_path)
|
|
201
|
+
if not directory.exists():
|
|
202
|
+
directory.mkdir(parents=True)
|
|
203
|
+
path: Path = directory / f"ft_cookies{self.username}.json"
|
|
196
204
|
else:
|
|
197
|
-
path = f"ft_cookies{self.username}.
|
|
198
|
-
with open(
|
|
199
|
-
ftat = self.session.headers.get("ftat")
|
|
200
|
-
|
|
205
|
+
path = Path(f"ft_cookies{self.username}.json")
|
|
206
|
+
with path.open("w") as f:
|
|
207
|
+
ftat: str | None = self.session.headers.get("ftat")
|
|
208
|
+
json.dump(obj=ftat, fp=f)
|
|
201
209
|
|
|
202
210
|
@staticmethod
|
|
203
|
-
def _mask_email(email):
|
|
204
|
-
"""
|
|
205
|
-
Masks the email for use in the API.
|
|
211
|
+
def _mask_email(email: str) -> str:
|
|
212
|
+
"""Mask the email for use in the API.
|
|
206
213
|
|
|
207
214
|
Args:
|
|
208
215
|
email (str): The email address to be masked.
|
|
209
216
|
|
|
210
217
|
Returns:
|
|
211
218
|
str: The masked email address.
|
|
219
|
+
|
|
212
220
|
"""
|
|
213
|
-
local, domain = email.split("@")
|
|
214
|
-
masked_local = local[0] + "*" * 4
|
|
221
|
+
local, domain = email.split(sep="@")
|
|
222
|
+
masked_local: str = local[0] + "*" * 4
|
|
215
223
|
domain_name, tld = domain.split(".")
|
|
216
|
-
masked_domain = domain_name[0] + "*" * 4
|
|
224
|
+
masked_domain: str = domain_name[0] + "*" * 4
|
|
217
225
|
return f"{masked_local}@{masked_domain}.{tld}"
|
|
218
226
|
|
|
219
|
-
def _handle_mfa(self):
|
|
220
|
-
"""
|
|
221
|
-
Handles multi-factor authentication.
|
|
227
|
+
def _handle_mfa(self) -> bool:
|
|
228
|
+
"""Handle multi-factor authentication.
|
|
222
229
|
|
|
223
230
|
This method processes the MFA requirements based on the login response and user-provided details.
|
|
224
231
|
|
|
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
232
|
"""
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
response = self.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if self.phone in item["recipientMask"]:
|
|
243
|
-
data = {
|
|
244
|
-
"recipientId": item["recipientId"],
|
|
245
|
-
"t_token": self.t_token,
|
|
246
|
-
}
|
|
247
|
-
break
|
|
248
|
-
elif item["channel"] == "email" and self.email is not None:
|
|
249
|
-
if self.email == item["recipientMask"]:
|
|
250
|
-
data = {
|
|
251
|
-
"recipientId": item["recipientId"],
|
|
252
|
-
"t_token": self.t_token,
|
|
253
|
-
}
|
|
254
|
-
break
|
|
255
|
-
response = self.session.post(urls.request_code(), data=data)
|
|
256
|
-
elif self.login_json["mfa"] and self.mfa_secret is not None:
|
|
257
|
-
mfa_otp = pyotp.TOTP(self.mfa_secret).now()
|
|
258
|
-
data = {
|
|
259
|
-
"mfaCode": mfa_otp,
|
|
260
|
-
"remember_for": "30",
|
|
261
|
-
"t_token": self.t_token,
|
|
262
|
-
}
|
|
263
|
-
response = self.session.post(urls.verify_pin(), data=data)
|
|
233
|
+
response: requests.Response | None = None
|
|
234
|
+
data: dict[str, str | None] = {}
|
|
235
|
+
|
|
236
|
+
if self.pin:
|
|
237
|
+
response: requests.Response = self._handle_pin_mfa(data)
|
|
238
|
+
elif (self.email or self.phone) and not self.mfa_secret:
|
|
239
|
+
response: requests.Response = self._handle_otp_mfa(data)
|
|
240
|
+
elif self.mfa_secret:
|
|
241
|
+
response: requests.Response = self._handle_secret_mfa(data)
|
|
242
|
+
else:
|
|
243
|
+
error_msg = "MFA required but no valid MFA method was provided (pin, email/phone, or mfa_secret)."
|
|
244
|
+
raise LoginError(error_msg)
|
|
245
|
+
|
|
264
246
|
self.login_json = response.json()
|
|
265
|
-
if self.login_json["error"]
|
|
266
|
-
|
|
267
|
-
self.session.headers["sid"] = self.login_json["sid"]
|
|
268
|
-
return False
|
|
269
|
-
self.session.headers["sid"] = self.login_json["verificationSid"]
|
|
270
|
-
return True
|
|
247
|
+
if self.login_json["error"]:
|
|
248
|
+
raise LoginResponseError(self.login_json["error"])
|
|
271
249
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
250
|
+
if self.pin or self.mfa_secret:
|
|
251
|
+
self.session.headers["sid"] = self.login_json["sid"]
|
|
252
|
+
return False
|
|
253
|
+
self.session.headers["sid"] = self.login_json["verificationSid"]
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
def _handle_pin_mfa(self, data: dict[str, str | None]) -> requests.Response:
|
|
257
|
+
"""Handle PIN-based MFA."""
|
|
258
|
+
data.update({
|
|
259
|
+
"pin": self.pin,
|
|
260
|
+
"remember_for": "30",
|
|
261
|
+
"t_token": self.t_token,
|
|
262
|
+
})
|
|
263
|
+
return self._request("post", urls.verify_pin(), data=data)
|
|
264
|
+
|
|
265
|
+
def _handle_otp_mfa(self, data: dict[str, str | None]) -> requests.Response:
|
|
266
|
+
"""Handle email/phone OTP-based MFA."""
|
|
267
|
+
if not self.otp_options:
|
|
268
|
+
error_msg = "No OTP options available."
|
|
269
|
+
raise LoginResponseError(error_msg)
|
|
270
|
+
|
|
271
|
+
for item in self.otp_options:
|
|
272
|
+
if (item["channel"] == "sms" and self.phone and self.phone in item["recipientMask"]) or (item["channel"] == "email" and self.email and self.email == item["recipientMask"]):
|
|
273
|
+
data.update({
|
|
274
|
+
"recipientId": item["recipientId"],
|
|
275
|
+
"t_token": self.t_token,
|
|
276
|
+
})
|
|
277
|
+
break
|
|
278
|
+
|
|
279
|
+
return self._request("post", urls.request_code(), data=data)
|
|
280
|
+
|
|
281
|
+
def _handle_secret_mfa(self, data: dict[str, str | None]) -> requests.Response:
|
|
282
|
+
"""Handle MFA secret-based authentication."""
|
|
283
|
+
mfa_otp = pyotp.TOTP(self.mfa_secret).now()
|
|
284
|
+
data.update({
|
|
285
|
+
"mfaCode": mfa_otp,
|
|
286
|
+
"remember_for": "30",
|
|
287
|
+
"t_token": self.t_token,
|
|
288
|
+
})
|
|
289
|
+
return self._request("post", urls.verify_pin(), data=data)
|
|
290
|
+
|
|
291
|
+
def _request(self, method, url, **kwargs):
|
|
292
|
+
"""Send HTTP request and log the full response content if debug=True."""
|
|
293
|
+
resp = self.session.request(method, url, **kwargs)
|
|
294
|
+
|
|
295
|
+
if self.debug:
|
|
296
|
+
# Suppress urllib3 / http.client debug so we only see this log
|
|
297
|
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
298
|
+
|
|
299
|
+
# Basic request info
|
|
300
|
+
logger.debug(f">>> {method.upper()} {url}")
|
|
301
|
+
logger.debug(f"<<< Status: {resp.status_code}")
|
|
302
|
+
logger.debug(f"<<< Headers: {resp.headers}")
|
|
303
|
+
|
|
304
|
+
# Log raw bytes length
|
|
305
|
+
try:
|
|
306
|
+
logger.debug(f"<<< Raw bytes length: {len(resp.content)}")
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.debug(f"<<< Could not read raw bytes: {e}")
|
|
309
|
+
|
|
310
|
+
# Log pretty JSON (if any)
|
|
311
|
+
try:
|
|
312
|
+
import json as pyjson
|
|
313
|
+
# This automatically uses requests decompression if gzip is set
|
|
314
|
+
json_body = resp.json()
|
|
315
|
+
pretty = pyjson.dumps(json_body, indent=2)
|
|
316
|
+
logger.debug(f"<<< JSON body:\n{pretty}")
|
|
317
|
+
except Exception as e:
|
|
318
|
+
# If JSON decoding fails, fallback to raw text
|
|
319
|
+
try:
|
|
320
|
+
logger.debug(f"<<< Body (text):\n{resp.text}")
|
|
321
|
+
except Exception as e2:
|
|
322
|
+
logger.debug(f"<<< Could not read body text: {e2}")
|
|
323
|
+
|
|
324
|
+
return resp
|
|
325
|
+
|
|
326
|
+
def __getattr__(self, name: str) -> object:
|
|
327
|
+
"""Forward unknown attribute access to session object.
|
|
275
328
|
|
|
276
329
|
Args:
|
|
277
330
|
name (str): The name of the attribute to be accessed.
|
|
278
331
|
|
|
279
332
|
Returns:
|
|
280
333
|
The value of the requested attribute from the session object.
|
|
334
|
+
|
|
281
335
|
"""
|
|
282
336
|
return getattr(self.session, name)
|
|
283
337
|
|
|
@@ -285,21 +339,20 @@ class FTSession:
|
|
|
285
339
|
class FTAccountData:
|
|
286
340
|
"""Dataclass for storing account information."""
|
|
287
341
|
|
|
288
|
-
def __init__(self, session):
|
|
289
|
-
"""
|
|
290
|
-
Initializes a new instance of the FTAccountData class.
|
|
342
|
+
def __init__(self, session: requests.Session) -> None:
|
|
343
|
+
"""Initialize a new instance of the FTAccountData class.
|
|
291
344
|
|
|
292
345
|
Args:
|
|
293
|
-
session (requests.Session):
|
|
294
|
-
|
|
346
|
+
session (requests.Session): The session object used for making HTTP requests.
|
|
347
|
+
|
|
295
348
|
"""
|
|
296
|
-
self.session = session
|
|
297
|
-
self.all_accounts = []
|
|
298
|
-
self.account_numbers = []
|
|
299
|
-
self.account_balances = {}
|
|
300
|
-
response = self.session.get
|
|
301
|
-
self.user_info = response.json()
|
|
302
|
-
response = self.session.get
|
|
349
|
+
self.session: requests.Session = session
|
|
350
|
+
self.all_accounts: list[dict[str, object]] = []
|
|
351
|
+
self.account_numbers: list[str] = []
|
|
352
|
+
self.account_balances: dict[str, object] = {}
|
|
353
|
+
response: requests.Response = self.session._request("get", url=urls.user_info())
|
|
354
|
+
self.user_info: dict[str, object] = response.json()
|
|
355
|
+
response: requests.Response = self.session._request("get", urls.account_list())
|
|
303
356
|
if response.status_code != 200 or response.json()["error"] != "":
|
|
304
357
|
raise AccountResponseError(response.json()["error"])
|
|
305
358
|
self.all_accounts = response.json()
|
|
@@ -307,90 +360,93 @@ class FTAccountData:
|
|
|
307
360
|
self.account_numbers.append(item["account"])
|
|
308
361
|
self.account_balances[item["account"]] = item["total_value"]
|
|
309
362
|
|
|
310
|
-
def get_account_balances(self, account):
|
|
311
|
-
"""
|
|
363
|
+
def get_account_balances(self, account: str) -> dict[str, object]:
|
|
364
|
+
"""Get account balances for a given account.
|
|
312
365
|
|
|
313
366
|
Args:
|
|
314
367
|
account (str): Account number of the account you want to get balances for.
|
|
315
368
|
|
|
316
369
|
Returns:
|
|
317
370
|
dict: Dict of the response from the API.
|
|
371
|
+
|
|
318
372
|
"""
|
|
319
|
-
response = self.session.get
|
|
373
|
+
response: requests.Response = self.session._request("get", urls.account_balances(account))
|
|
320
374
|
return response.json()
|
|
321
375
|
|
|
322
|
-
def get_positions(self, account):
|
|
323
|
-
"""
|
|
376
|
+
def get_positions(self, account: str) -> dict[str, object]:
|
|
377
|
+
"""Get currently held positions for a given account.
|
|
324
378
|
|
|
325
379
|
Args:
|
|
326
380
|
account (str): Account number of the account you want to get positions for.
|
|
327
381
|
|
|
328
382
|
Returns:
|
|
329
383
|
dict: Dict of the response from the API.
|
|
330
|
-
"""
|
|
331
384
|
|
|
332
|
-
|
|
385
|
+
"""
|
|
386
|
+
response = self.session._request("get", urls.account_positions(account))
|
|
333
387
|
return response.json()
|
|
334
388
|
|
|
335
|
-
def get_account_history(
|
|
336
|
-
|
|
389
|
+
def get_account_history(
|
|
390
|
+
self,
|
|
391
|
+
account: str,
|
|
392
|
+
date_range: str = "ytd",
|
|
393
|
+
custom_range: list[str] | None = None,
|
|
394
|
+
) -> dict[str, object]:
|
|
395
|
+
"""Get account history for a given account.
|
|
337
396
|
|
|
338
397
|
Args:
|
|
339
398
|
account (str): Account number of the account you want to get history for.
|
|
340
|
-
|
|
399
|
+
date_range (str): The range of the history. Defaults to "ytd".
|
|
341
400
|
Available options are
|
|
342
401
|
["today", "1w", "1m", "2m", "mtd", "ytd", "ly", "cust"].
|
|
343
|
-
custom_range (str): The custom range of the history.
|
|
402
|
+
custom_range (list[str] | None): The custom range of the history.
|
|
344
403
|
Defaults to None. If range is "cust",
|
|
345
404
|
this parameter is required.
|
|
346
405
|
Format: ["YYYY-MM-DD", "YYYY-MM-DD"].
|
|
347
406
|
|
|
348
407
|
Returns:
|
|
349
408
|
dict: Dict of the response from the API.
|
|
409
|
+
|
|
350
410
|
"""
|
|
351
411
|
if date_range == "cust" and custom_range is None:
|
|
352
|
-
raise ValueError("Custom range
|
|
353
|
-
response = self.session.
|
|
354
|
-
urls.account_history(account, date_range, custom_range)
|
|
412
|
+
raise ValueError("Custom range required.")
|
|
413
|
+
response: requests.Response = self.session._request(
|
|
414
|
+
"get", urls.account_history(account, date_range, custom_range),
|
|
355
415
|
)
|
|
356
416
|
return response.json()
|
|
357
417
|
|
|
358
|
-
def get_orders(self, account):
|
|
359
|
-
"""
|
|
360
|
-
Retrieves existing order data for a given account.
|
|
418
|
+
def get_orders(self, account: str) -> list[dict[str, object]]:
|
|
419
|
+
"""Retrieve existing order data for a given account.
|
|
361
420
|
|
|
362
421
|
Args:
|
|
363
|
-
ft_session (FTSession): The session object used for making HTTP requests to Firstrade.
|
|
364
422
|
account (str): Account number of the account to retrieve orders for.
|
|
365
423
|
|
|
366
424
|
Returns:
|
|
367
425
|
list: A list of dictionaries, each containing details about an order.
|
|
368
|
-
"""
|
|
369
426
|
|
|
370
|
-
|
|
427
|
+
"""
|
|
428
|
+
response = self.session._request("get", url=urls.order_list(account))
|
|
371
429
|
return response.json()
|
|
372
430
|
|
|
373
|
-
def cancel_order(self, order_id):
|
|
374
|
-
"""
|
|
375
|
-
Cancels an existing order.
|
|
431
|
+
def cancel_order(self, order_id: str) -> dict[str, object]:
|
|
432
|
+
"""Cancel an existing order.
|
|
376
433
|
|
|
377
434
|
Args:
|
|
378
435
|
order_id (str): The order ID to cancel.
|
|
379
436
|
|
|
380
437
|
Returns:
|
|
381
438
|
dict: A dictionary containing the response data.
|
|
382
|
-
"""
|
|
383
439
|
|
|
440
|
+
"""
|
|
384
441
|
data = {
|
|
385
442
|
"order_id": order_id,
|
|
386
443
|
}
|
|
387
444
|
|
|
388
|
-
response = self.session.post
|
|
445
|
+
response = self.session._request("post", url=urls.cancel_order(), data=data)
|
|
389
446
|
return response.json()
|
|
390
447
|
|
|
391
|
-
def get_balance_overview(self, account, keywords=None):
|
|
392
|
-
"""
|
|
393
|
-
Returns a filtered, flattened view of useful balance fields.
|
|
448
|
+
def get_balance_overview(self, account: str, keywords: list[str] | None = None) -> dict[str, object]:
|
|
449
|
+
"""Return a filtered, flattened view of useful balance fields.
|
|
394
450
|
|
|
395
451
|
This is a convenience helper over `get_account_balances` to quickly
|
|
396
452
|
surface likely relevant numbers such as cash, available cash, and
|
|
@@ -404,6 +460,7 @@ class FTAccountData:
|
|
|
404
460
|
Returns:
|
|
405
461
|
dict: A dict mapping dot-notated keys to values from the balances
|
|
406
462
|
response where the key path contains any of the keywords.
|
|
463
|
+
|
|
407
464
|
"""
|
|
408
465
|
if keywords is None:
|
|
409
466
|
keywords = [
|
|
@@ -417,22 +474,22 @@ class FTAccountData:
|
|
|
417
474
|
"margin",
|
|
418
475
|
]
|
|
419
476
|
|
|
420
|
-
payload = self.get_account_balances(account)
|
|
477
|
+
payload: dict[str, object] = self.get_account_balances(account)
|
|
421
478
|
|
|
422
|
-
filtered = {}
|
|
479
|
+
filtered: dict[str, object] = {}
|
|
423
480
|
|
|
424
|
-
def _walk(node, path):
|
|
481
|
+
def _walk(node: object, path: list[str]) -> None:
|
|
425
482
|
if isinstance(node, dict):
|
|
426
483
|
for k, v in node.items():
|
|
427
|
-
_walk(v, path
|
|
484
|
+
_walk(node=v, path=[*path, str(object=k)])
|
|
428
485
|
elif isinstance(node, list):
|
|
429
|
-
for i, v in enumerate(node):
|
|
430
|
-
_walk(v, path
|
|
486
|
+
for i, v in enumerate(iterable=node):
|
|
487
|
+
_walk(node=v, path=[*path, str(object=i)])
|
|
431
488
|
else:
|
|
432
|
-
key_path = ".".join(path)
|
|
433
|
-
low = key_path.lower()
|
|
489
|
+
key_path: str = ".".join(path)
|
|
490
|
+
low: str = key_path.lower()
|
|
434
491
|
if any(sub in low for sub in keywords):
|
|
435
492
|
filtered[key_path] = node
|
|
436
493
|
|
|
437
|
-
_walk(payload, [])
|
|
494
|
+
_walk(node=payload, path=[])
|
|
438
495
|
return filtered
|
firstrade/exceptions.py
CHANGED
|
@@ -36,10 +36,9 @@ class LoginRequestError(LoginError):
|
|
|
36
36
|
class LoginResponseError(LoginError):
|
|
37
37
|
"""Exception raised for errors in the API response during login."""
|
|
38
38
|
|
|
39
|
-
def __init__(self, error_message):
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)
|
|
39
|
+
def __init__(self, error_message: str) -> None:
|
|
40
|
+
"""Raise error for login response issues."""
|
|
41
|
+
self.message = f"Failed to login. API returned the following error: {error_message}"
|
|
43
42
|
super().__init__(self.message)
|
|
44
43
|
|
|
45
44
|
|
firstrade/order.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
import enum
|
|
2
2
|
|
|
3
3
|
from firstrade import urls
|
|
4
4
|
from firstrade.account import FTSession
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
class PriceType(
|
|
8
|
-
"""
|
|
9
|
-
Enum for valid price types in an order.
|
|
7
|
+
class PriceType(enum.StrEnum):
|
|
8
|
+
"""Enum for valid price types in an order.
|
|
10
9
|
|
|
11
10
|
Attributes:
|
|
12
11
|
MARKET (str): Market order, executed at the current market price.
|
|
@@ -15,6 +14,7 @@ class PriceType(str, Enum):
|
|
|
15
14
|
STOP_LIMIT (str): Stop-limit order, becomes a limit order once a specified price is reached.
|
|
16
15
|
TRAILING_STOP_DOLLAR (str): Trailing stop order with a specified dollar amount.
|
|
17
16
|
TRAILING_STOP_PERCENT (str): Trailing stop order with a specified percentage.
|
|
17
|
+
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
LIMIT = "2"
|
|
@@ -25,9 +25,8 @@ class PriceType(str, Enum):
|
|
|
25
25
|
TRAILING_STOP_PERCENT = "6"
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
class Duration(
|
|
29
|
-
"""
|
|
30
|
-
Enum for valid order durations.
|
|
28
|
+
class Duration(enum.StrEnum):
|
|
29
|
+
"""Enum for valid order durations.
|
|
31
30
|
|
|
32
31
|
Attributes:
|
|
33
32
|
DAY (str): Day order.
|
|
@@ -35,6 +34,7 @@ class Duration(str, Enum):
|
|
|
35
34
|
PRE_MARKET (str): Pre-market order.
|
|
36
35
|
AFTER_MARKET (str): After-market order.
|
|
37
36
|
DAY_EXT (str): Day extended order.
|
|
37
|
+
|
|
38
38
|
"""
|
|
39
39
|
|
|
40
40
|
DAY = "0"
|
|
@@ -44,9 +44,8 @@ class Duration(str, Enum):
|
|
|
44
44
|
DAY_EXT = "D"
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
class OrderType(
|
|
48
|
-
"""
|
|
49
|
-
Enum for valid order types.
|
|
47
|
+
class OrderType(enum.StrEnum):
|
|
48
|
+
"""Enum for valid order types.
|
|
50
49
|
|
|
51
50
|
Attributes:
|
|
52
51
|
BUY (str): Buy order.
|
|
@@ -55,6 +54,7 @@ class OrderType(str, Enum):
|
|
|
55
54
|
BUY_TO_COVER (str): Buy to cover order.
|
|
56
55
|
BUY_OPTION (str): Buy option order.
|
|
57
56
|
SELL_OPTION (str): Sell option order.
|
|
57
|
+
|
|
58
58
|
"""
|
|
59
59
|
|
|
60
60
|
BUY = "B"
|
|
@@ -65,28 +65,30 @@ class OrderType(str, Enum):
|
|
|
65
65
|
SELL_OPTION = "SO"
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
class OrderInstructions(
|
|
69
|
-
"""
|
|
70
|
-
Enum for valid order instructions.
|
|
68
|
+
class OrderInstructions(enum.StrEnum):
|
|
69
|
+
"""Enum for valid order instructions.
|
|
71
70
|
|
|
72
71
|
Attributes:
|
|
72
|
+
NONE (str): No special instruction.
|
|
73
73
|
AON (str): All or none.
|
|
74
74
|
OPG (str): At the Open.
|
|
75
75
|
CLO (str): At the Close.
|
|
76
|
+
|
|
76
77
|
"""
|
|
77
78
|
|
|
79
|
+
NONE = "0"
|
|
78
80
|
AON = "1"
|
|
79
81
|
OPG = "4"
|
|
80
82
|
CLO = "5"
|
|
81
83
|
|
|
82
84
|
|
|
83
|
-
class OptionType(
|
|
84
|
-
"""
|
|
85
|
-
Enum for valid option types.
|
|
85
|
+
class OptionType(enum.StrEnum):
|
|
86
|
+
"""Enum for valid option types.
|
|
86
87
|
|
|
87
88
|
Attributes:
|
|
88
89
|
CALL (str): Call option.
|
|
89
90
|
PUT (str): Put option.
|
|
91
|
+
|
|
90
92
|
"""
|
|
91
93
|
|
|
92
94
|
CALL = "C"
|
|
@@ -94,15 +96,16 @@ class OptionType(str, Enum):
|
|
|
94
96
|
|
|
95
97
|
|
|
96
98
|
class Order:
|
|
97
|
-
"""
|
|
98
|
-
Represents an order with methods to place it.
|
|
99
|
+
"""Represents an order with methods to place it.
|
|
99
100
|
|
|
100
101
|
Attributes:
|
|
101
102
|
ft_session (FTSession): The session object for placing orders.
|
|
103
|
+
|
|
102
104
|
"""
|
|
103
105
|
|
|
104
|
-
def __init__(self, ft_session: FTSession):
|
|
105
|
-
|
|
106
|
+
def __init__(self, ft_session: FTSession) -> None:
|
|
107
|
+
"""Initialize the Order with a FirstTrade session."""
|
|
108
|
+
self.ft_session: FTSession = ft_session
|
|
106
109
|
|
|
107
110
|
def place_order(
|
|
108
111
|
self,
|
|
@@ -113,13 +116,13 @@ class Order:
|
|
|
113
116
|
duration: Duration,
|
|
114
117
|
quantity: int = 0,
|
|
115
118
|
price: float = 0.00,
|
|
116
|
-
stop_price: float = None,
|
|
119
|
+
stop_price: float | None = None,
|
|
120
|
+
*,
|
|
117
121
|
dry_run: bool = True,
|
|
118
122
|
notional: bool = False,
|
|
119
|
-
order_instruction: OrderInstructions =
|
|
123
|
+
order_instruction: OrderInstructions = OrderInstructions.NONE,
|
|
120
124
|
):
|
|
121
|
-
"""
|
|
122
|
-
Builds and places an order.
|
|
125
|
+
"""Build and place an order.
|
|
123
126
|
|
|
124
127
|
Args:
|
|
125
128
|
account (str): The account number to place the order in.
|
|
@@ -134,15 +137,10 @@ class Order:
|
|
|
134
137
|
notional (bool, optional): If True, the order will be placed based on a notional dollar amount rather than share quantity. Defaults to False.
|
|
135
138
|
order_instruction (OrderInstructions, optional): Additional order instructions (e.g., AON, OPG). Defaults to "0".
|
|
136
139
|
|
|
137
|
-
Raises:
|
|
138
|
-
ValueError: If AON orders are not limit orders or if AON orders have a quantity of 100 shares or less.
|
|
139
|
-
PreviewOrderError: If the order preview fails.
|
|
140
|
-
PlaceOrderError: If the order placement fails.
|
|
141
|
-
|
|
142
140
|
Returns:
|
|
143
141
|
dict: A dictionary containing the order confirmation data.
|
|
144
|
-
"""
|
|
145
142
|
|
|
143
|
+
"""
|
|
146
144
|
if price_type == PriceType.MARKET and not notional:
|
|
147
145
|
price = ""
|
|
148
146
|
if order_instruction == OrderInstructions.AON and price_type != PriceType.LIMIT:
|
|
@@ -164,11 +162,11 @@ class Order:
|
|
|
164
162
|
if notional:
|
|
165
163
|
data["dollar_amount"] = price
|
|
166
164
|
del data["shares"]
|
|
167
|
-
if price_type in
|
|
165
|
+
if price_type in {PriceType.LIMIT, PriceType.STOP_LIMIT}:
|
|
168
166
|
data["limit_price"] = price
|
|
169
|
-
if price_type in
|
|
167
|
+
if price_type in {PriceType.STOP, PriceType.STOP_LIMIT}:
|
|
170
168
|
data["stop_price"] = stop_price
|
|
171
|
-
response = self.ft_session.post
|
|
169
|
+
response: requests.Response = self.ft_session._request("post", url=urls.order(), data=data)
|
|
172
170
|
if response.status_code != 200 or response.json()["error"] != "":
|
|
173
171
|
return response.json()
|
|
174
172
|
preview_data = response.json()
|
|
@@ -176,7 +174,7 @@ class Order:
|
|
|
176
174
|
return preview_data
|
|
177
175
|
data["preview"] = "false"
|
|
178
176
|
data["stage"] = "P"
|
|
179
|
-
response = self.ft_session.post
|
|
177
|
+
response = self.ft_session._request("post", url=urls.order(), data=data)
|
|
180
178
|
return response.json()
|
|
181
179
|
|
|
182
180
|
def place_option_order(
|
|
@@ -187,13 +185,13 @@ class Order:
|
|
|
187
185
|
order_type: OrderType,
|
|
188
186
|
contracts: int,
|
|
189
187
|
duration: Duration,
|
|
190
|
-
stop_price: float = None,
|
|
188
|
+
stop_price: float | None = None,
|
|
191
189
|
price: float = 0.00,
|
|
190
|
+
*,
|
|
192
191
|
dry_run: bool = True,
|
|
193
|
-
order_instruction: OrderInstructions =
|
|
192
|
+
order_instruction: OrderInstructions = OrderInstructions.NONE,
|
|
194
193
|
):
|
|
195
|
-
"""
|
|
196
|
-
Builds and places an option order.
|
|
194
|
+
"""Build and place an option order.
|
|
197
195
|
|
|
198
196
|
Args:
|
|
199
197
|
account (str): The account number to place the order in.
|
|
@@ -209,13 +207,11 @@ class Order:
|
|
|
209
207
|
|
|
210
208
|
Raises:
|
|
211
209
|
ValueError: If AON orders are not limit orders or if AON orders have a quantity of 100 contracts or less.
|
|
212
|
-
PreviewOrderError: If there is an error during the preview of the order.
|
|
213
|
-
PlaceOrderError: If there is an error during the placement of the order.
|
|
214
210
|
|
|
215
211
|
Returns:
|
|
216
212
|
dict: A dictionary containing the order confirmation data.
|
|
217
|
-
"""
|
|
218
213
|
|
|
214
|
+
"""
|
|
219
215
|
if order_instruction == OrderInstructions.AON and price_type != PriceType.LIMIT:
|
|
220
216
|
raise ValueError("AON orders must be a limit order.")
|
|
221
217
|
if order_instruction == OrderInstructions.AON and contracts <= 100:
|
|
@@ -231,16 +227,16 @@ class Order:
|
|
|
231
227
|
"account": account,
|
|
232
228
|
"price_type": price_type,
|
|
233
229
|
}
|
|
234
|
-
if price_type in
|
|
230
|
+
if price_type in {PriceType.LIMIT, PriceType.STOP_LIMIT}:
|
|
235
231
|
data["limit_price"] = price
|
|
236
|
-
if price_type in
|
|
232
|
+
if price_type in {PriceType.STOP, PriceType.STOP_LIMIT}:
|
|
237
233
|
data["stop_price"] = stop_price
|
|
238
234
|
|
|
239
|
-
response = self.ft_session.post
|
|
235
|
+
response = self.ft_session._request("post", url=urls.option_order(), data=data)
|
|
240
236
|
if response.status_code != 200 or response.json()["error"] != "":
|
|
241
237
|
return response.json()
|
|
242
238
|
if dry_run:
|
|
243
239
|
return response.json()
|
|
244
240
|
data["preview"] = "false"
|
|
245
|
-
response = self.ft_session.post
|
|
241
|
+
response = self.ft_session._request("post", url=urls.option_order(), data=data)
|
|
246
242
|
return response.json()
|
firstrade/symbols.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
1
3
|
from firstrade import urls
|
|
2
4
|
from firstrade.account import FTSession
|
|
3
5
|
from firstrade.exceptions import QuoteRequestError, QuoteResponseError
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
class SymbolQuote:
|
|
7
|
-
"""
|
|
8
|
-
Data class representing a stock quote for a given symbol.
|
|
9
|
+
"""Data class representing a stock quote for a given symbol.
|
|
9
10
|
|
|
10
11
|
Attributes:
|
|
11
12
|
ft_session (FTSession): The session object used for making HTTP requests to Firstrade.
|
|
@@ -38,11 +39,11 @@ class SymbolQuote:
|
|
|
38
39
|
realtime (str): Indicates if the quote is real-time.
|
|
39
40
|
nls (str): Nasdaq last sale.
|
|
40
41
|
shares (int): The number of shares.
|
|
42
|
+
|
|
41
43
|
"""
|
|
42
44
|
|
|
43
45
|
def __init__(self, ft_session: FTSession, account: str, symbol: str):
|
|
44
|
-
"""
|
|
45
|
-
Initializes a new instance of the SymbolQuote class.
|
|
46
|
+
"""Initialize a new instance of the SymbolQuote class.
|
|
46
47
|
|
|
47
48
|
Args:
|
|
48
49
|
ft_session (FTSession): The session object used for making HTTP requests to Firstrade.
|
|
@@ -52,70 +53,70 @@ class SymbolQuote:
|
|
|
52
53
|
Raises:
|
|
53
54
|
QuoteRequestError: If the quote request fails with a non-200 status code.
|
|
54
55
|
QuoteResponseError: If the quote response contains an error message.
|
|
56
|
+
|
|
55
57
|
"""
|
|
56
|
-
self.ft_session = ft_session
|
|
57
|
-
response = self.ft_session.get
|
|
58
|
+
self.ft_session: FTSession = ft_session
|
|
59
|
+
response = self.ft_session._request("get", url=urls.quote(account, symbol))
|
|
58
60
|
if response.status_code != 200:
|
|
59
61
|
raise QuoteRequestError(response.status_code)
|
|
60
|
-
if response.json().get("error", "")
|
|
62
|
+
if response.json().get("error", ""):
|
|
61
63
|
raise QuoteResponseError(symbol, response.json()["error"])
|
|
62
|
-
self.symbol = response.json()["result"]["symbol"]
|
|
63
|
-
self.sec_type = response.json()["result"]["sec_type"]
|
|
64
|
-
self.tick = response.json()["result"]["tick"]
|
|
65
|
-
self.bid = response.json()["result"]["bid"]
|
|
66
|
-
self.bid_size = response.json()["result"]["bid_size"]
|
|
67
|
-
self.ask = response.json()["result"]["ask"]
|
|
68
|
-
self.ask_size = response.json()["result"]["ask_size"]
|
|
69
|
-
self.last = response.json()["result"]["last"]
|
|
70
|
-
self.change = response.json()["result"]["change"]
|
|
71
|
-
self.high = response.json()["result"]["high"]
|
|
72
|
-
self.low = response.json()["result"]["low"]
|
|
73
|
-
self.bid_mmid = response.json()["result"]["bid_mmid"]
|
|
74
|
-
self.ask_mmid = response.json()["result"]["ask_mmid"]
|
|
75
|
-
self.last_mmid = response.json()["result"]["last_mmid"]
|
|
76
|
-
self.last_size = response.json()["result"]["last_size"]
|
|
77
|
-
self.change_color = response.json()["result"]["change_color"]
|
|
78
|
-
self.volume = response.json()["result"]["vol"]
|
|
79
|
-
self.today_close = response.json()["result"]["today_close"]
|
|
80
|
-
self.open = response.json()["result"]["open"]
|
|
81
|
-
self.quote_time = response.json()["result"]["quote_time"]
|
|
82
|
-
self.last_trade_time = response.json()["result"]["last_trade_time"]
|
|
83
|
-
self.company_name = response.json()["result"]["company_name"]
|
|
84
|
-
self.exchange = response.json()["result"]["exchange"]
|
|
85
|
-
self.has_option = response.json()["result"]["has_option"]
|
|
86
|
-
self.is_etf = bool(response.json()["result"]["is_etf"])
|
|
64
|
+
self.symbol: str = response.json()["result"]["symbol"]
|
|
65
|
+
self.sec_type: str = response.json()["result"]["sec_type"]
|
|
66
|
+
self.tick: str = response.json()["result"]["tick"]
|
|
67
|
+
self.bid: str = response.json()["result"]["bid"]
|
|
68
|
+
self.bid_size: str = response.json()["result"]["bid_size"]
|
|
69
|
+
self.ask: str = response.json()["result"]["ask"]
|
|
70
|
+
self.ask_size: str = response.json()["result"]["ask_size"]
|
|
71
|
+
self.last: str = response.json()["result"]["last"]
|
|
72
|
+
self.change: str = response.json()["result"]["change"]
|
|
73
|
+
self.high: str = response.json()["result"]["high"]
|
|
74
|
+
self.low: str = response.json()["result"]["low"]
|
|
75
|
+
self.bid_mmid: str = response.json()["result"]["bid_mmid"]
|
|
76
|
+
self.ask_mmid: str = response.json()["result"]["ask_mmid"]
|
|
77
|
+
self.last_mmid: str = response.json()["result"]["last_mmid"]
|
|
78
|
+
self.last_size: int = response.json()["result"]["last_size"]
|
|
79
|
+
self.change_color: str = response.json()["result"]["change_color"]
|
|
80
|
+
self.volume: str = response.json()["result"]["vol"]
|
|
81
|
+
self.today_close: float = response.json()["result"]["today_close"]
|
|
82
|
+
self.open: str = response.json()["result"]["open"]
|
|
83
|
+
self.quote_time: str = response.json()["result"]["quote_time"]
|
|
84
|
+
self.last_trade_time: str = response.json()["result"]["last_trade_time"]
|
|
85
|
+
self.company_name: str = response.json()["result"]["company_name"]
|
|
86
|
+
self.exchange: str = response.json()["result"]["exchange"]
|
|
87
|
+
self.has_option: str = response.json()["result"]["has_option"]
|
|
88
|
+
self.is_etf: bool = bool(response.json()["result"]["is_etf"])
|
|
87
89
|
self.is_fractional = bool(response.json()["result"]["is_fractional"])
|
|
88
|
-
self.realtime = response.json()["result"]["realtime"]
|
|
89
|
-
self.nls = response.json()["result"]["nls"]
|
|
90
|
-
self.shares = response.json()["result"]["shares"]
|
|
90
|
+
self.realtime: str = response.json()["result"]["realtime"]
|
|
91
|
+
self.nls: str = response.json()["result"]["nls"]
|
|
92
|
+
self.shares: str = response.json()["result"]["shares"]
|
|
91
93
|
|
|
92
94
|
|
|
93
95
|
class OptionQuote:
|
|
94
|
-
"""
|
|
95
|
-
Data class representing an option quote for a given symbol.
|
|
96
|
+
"""Data class representing an option quote for a given symbol.
|
|
96
97
|
|
|
97
98
|
Attributes:
|
|
98
99
|
ft_session (FTSession): The session object used for making HTTP requests to Firstrade.
|
|
99
100
|
symbol (str): The symbol for which the option quote information is retrieved.
|
|
100
101
|
option_dates (dict): A dict of expiration dates for options on the given symbol.
|
|
102
|
+
|
|
101
103
|
"""
|
|
102
104
|
|
|
103
105
|
def __init__(self, ft_session: FTSession, symbol: str):
|
|
104
|
-
"""
|
|
105
|
-
Initializes a new instance of the OptionQuote class.
|
|
106
|
+
"""Initialize a new instance of the OptionQuote class.
|
|
106
107
|
|
|
107
108
|
Args:
|
|
108
109
|
ft_session (FTSession):
|
|
109
110
|
The session object used for making HTTP requests to Firstrade.
|
|
110
111
|
symbol (str): The symbol for which the option quote information is retrieved.
|
|
112
|
+
|
|
111
113
|
"""
|
|
112
114
|
self.ft_session = ft_session
|
|
113
115
|
self.symbol = symbol
|
|
114
116
|
self.option_dates = self.get_option_dates(symbol)
|
|
115
117
|
|
|
116
118
|
def get_option_dates(self, symbol: str):
|
|
117
|
-
"""
|
|
118
|
-
Retrieves the expiration dates for options on a given symbol.
|
|
119
|
+
"""Retrieve the expiration dates for options on a given symbol.
|
|
119
120
|
|
|
120
121
|
Args:
|
|
121
122
|
symbol (str): The symbol for which the expiration dates are retrieved.
|
|
@@ -123,16 +124,12 @@ class OptionQuote:
|
|
|
123
124
|
Returns:
|
|
124
125
|
dict: A dict of expiration dates and other information for options on the given symbol.
|
|
125
126
|
|
|
126
|
-
Raises:
|
|
127
|
-
QuoteRequestError: If the request for option dates fails with a non-200 status code.
|
|
128
|
-
QuoteResponseError: If the response for option dates contains an error message.
|
|
129
127
|
"""
|
|
130
|
-
response = self.ft_session.get
|
|
128
|
+
response = self.ft_session._request("get", url=urls.option_dates(symbol))
|
|
131
129
|
return response.json()
|
|
132
130
|
|
|
133
|
-
def get_option_quote(self, symbol: str, date: str):
|
|
134
|
-
"""
|
|
135
|
-
Retrieves the quote for a given option symbol.
|
|
131
|
+
def get_option_quote(self, symbol: str, date: str) -> dict[Any, Any]:
|
|
132
|
+
"""Retrieve the quote for a given option symbol.
|
|
136
133
|
|
|
137
134
|
Args:
|
|
138
135
|
symbol (str): The symbol for which the quote is retrieved.
|
|
@@ -140,16 +137,12 @@ class OptionQuote:
|
|
|
140
137
|
Returns:
|
|
141
138
|
dict: A dictionary containing the quote and other information for the given option symbol.
|
|
142
139
|
|
|
143
|
-
Raises:
|
|
144
|
-
QuoteRequestError: If the request for the option quote fails with a non-200 status code.
|
|
145
|
-
QuoteResponseError: If the response for the option quote contains an error message.
|
|
146
140
|
"""
|
|
147
|
-
response = self.ft_session.get
|
|
141
|
+
response = self.ft_session._request("get", url=urls.option_quotes(symbol, date))
|
|
148
142
|
return response.json()
|
|
149
143
|
|
|
150
144
|
def get_greek_options(self, symbol: str, exp_date: str):
|
|
151
|
-
"""
|
|
152
|
-
Retrieves the greeks for options on a given symbol.
|
|
145
|
+
"""Retrieve the greeks for options on a given symbol.
|
|
153
146
|
|
|
154
147
|
Args:
|
|
155
148
|
symbol (str): The symbol for which the greeks are retrieved.
|
|
@@ -158,9 +151,6 @@ class OptionQuote:
|
|
|
158
151
|
Returns:
|
|
159
152
|
dict: A dictionary containing the greeks for the options on the given symbol.
|
|
160
153
|
|
|
161
|
-
Raises:
|
|
162
|
-
QuoteRequestError: If the request for the greeks fails with a non-200 status code.
|
|
163
|
-
QuoteResponseError: If the response for the greeks contains an error message.
|
|
164
154
|
"""
|
|
165
155
|
data = {
|
|
166
156
|
"type": "chain",
|
|
@@ -168,5 +158,5 @@ class OptionQuote:
|
|
|
168
158
|
"root_symbol": symbol,
|
|
169
159
|
"exp_date": exp_date,
|
|
170
160
|
}
|
|
171
|
-
response = self.ft_session.post
|
|
161
|
+
response = self.ft_session._request("post", url=urls.greek_options(), data=data)
|
|
172
162
|
return response.json()
|
firstrade/urls.py
CHANGED
|
@@ -1,73 +1,88 @@
|
|
|
1
|
-
def login():
|
|
1
|
+
def login() -> str:
|
|
2
|
+
"""Login URL for FirstTrade API."""
|
|
2
3
|
return "https://api3x.firstrade.com/sess/login"
|
|
3
4
|
|
|
4
5
|
|
|
5
|
-
def request_code():
|
|
6
|
+
def request_code() -> str:
|
|
7
|
+
"""Request PIN/MFA option for FirstTrade API."""
|
|
6
8
|
return "https://api3x.firstrade.com/sess/request_code"
|
|
7
9
|
|
|
8
10
|
|
|
9
|
-
def verify_pin():
|
|
11
|
+
def verify_pin() -> str:
|
|
12
|
+
"""Request PIN/MFA verification for FirstTrade API."""
|
|
10
13
|
return "https://api3x.firstrade.com/sess/verify_pin"
|
|
11
14
|
|
|
12
15
|
|
|
13
|
-
def user_info():
|
|
16
|
+
def user_info() -> str:
|
|
17
|
+
"""Retrieve user information URL for FirstTrade API."""
|
|
14
18
|
return "https://api3x.firstrade.com/private/userinfo"
|
|
15
19
|
|
|
16
20
|
|
|
17
|
-
def account_list():
|
|
21
|
+
def account_list() -> str:
|
|
22
|
+
"""Retrieve account list URL for FirstTrade API."""
|
|
18
23
|
return "https://api3x.firstrade.com/private/acct_list"
|
|
19
24
|
|
|
20
25
|
|
|
21
|
-
def account_balances(account):
|
|
26
|
+
def account_balances(account: str) -> str:
|
|
27
|
+
"""Retrieve account balances URL for FirstTrade API."""
|
|
22
28
|
return f"https://api3x.firstrade.com/private/balances?account={account}"
|
|
23
29
|
|
|
24
30
|
|
|
25
|
-
def account_positions(account):
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
31
|
+
def account_positions(account: str) -> str:
|
|
32
|
+
"""Retrieve account positions URL for FirstTrade API."""
|
|
33
|
+
return f"https://api3x.firstrade.com/private/positions?account={account}&per_page=200"
|
|
29
34
|
|
|
30
35
|
|
|
31
|
-
def quote(account, symbol):
|
|
36
|
+
def quote(account: str, symbol: str) -> str:
|
|
37
|
+
"""Symbol quote URL for FirstTrade API."""
|
|
32
38
|
return f"https://api3x.firstrade.com/public/quote?account={account}&q={symbol}"
|
|
33
39
|
|
|
34
40
|
|
|
35
|
-
def order():
|
|
41
|
+
def order() -> str:
|
|
42
|
+
"""Place equity order URL for FirstTrade API."""
|
|
36
43
|
return "https://api3x.firstrade.com/private/stock_order"
|
|
37
44
|
|
|
38
45
|
|
|
39
|
-
def order_list(account):
|
|
46
|
+
def order_list(account: str) -> str:
|
|
47
|
+
"""Retrieve placed order list URL for FirstTrade API."""
|
|
40
48
|
return f"https://api3x.firstrade.com/private/order_status?account={account}"
|
|
41
49
|
|
|
42
50
|
|
|
43
|
-
def account_history(account, date_range, custom_range):
|
|
51
|
+
def account_history(account: str, date_range: str, custom_range: list[str] | None) -> str:
|
|
52
|
+
"""Retrieve account history URL for FirstTrade API."""
|
|
44
53
|
if custom_range is None:
|
|
45
54
|
return f"https://api3x.firstrade.com/private/account_history?range={date_range}&page=1&account={account}&per_page=1000"
|
|
46
55
|
return f"https://api3x.firstrade.com/private/account_history?range={date_range}&range_arr[]={custom_range[0]}&range_arr[]={custom_range[1]}&page=1&account={account}&per_page=1000"
|
|
47
56
|
|
|
48
57
|
|
|
49
|
-
def cancel_order():
|
|
58
|
+
def cancel_order() -> str:
|
|
59
|
+
"""Cancel placed order URL for FirstTrade API."""
|
|
50
60
|
return "https://api3x.firstrade.com/private/cancel_order"
|
|
51
61
|
|
|
52
62
|
|
|
53
|
-
def option_dates(symbol):
|
|
63
|
+
def option_dates(symbol: str) -> str:
|
|
64
|
+
"""Option dates URL for FirstTrade API."""
|
|
54
65
|
return f"https://api3x.firstrade.com/public/oc?m=get_exp_dates&root_symbol={symbol}"
|
|
55
66
|
|
|
56
67
|
|
|
57
|
-
def option_quotes(symbol, date):
|
|
68
|
+
def option_quotes(symbol: str, date: str) -> str:
|
|
69
|
+
"""Option quotes URL for FirstTrade API."""
|
|
58
70
|
return f"https://api3x.firstrade.com/public/oc?m=get_oc&root_symbol={symbol}&exp_date={date}&chains_range=A"
|
|
59
71
|
|
|
60
72
|
|
|
61
|
-
def greek_options():
|
|
73
|
+
def greek_options() -> str:
|
|
74
|
+
"""Greek options analytical data URL for FirstTrade API."""
|
|
62
75
|
return "https://api3x.firstrade.com/private/greekoptions/analytical"
|
|
63
76
|
|
|
64
77
|
|
|
65
|
-
def option_order():
|
|
78
|
+
def option_order() -> str:
|
|
79
|
+
"""Place option order URL for FirstTrade API."""
|
|
66
80
|
return "https://api3x.firstrade.com/private/option_order"
|
|
67
81
|
|
|
68
82
|
|
|
69
|
-
def session_headers():
|
|
70
|
-
headers
|
|
83
|
+
def session_headers() -> dict[str, str]:
|
|
84
|
+
"""Session headers for FirstTrade API."""
|
|
85
|
+
headers: dict[str, str] = {
|
|
71
86
|
"Accept-Encoding": "gzip",
|
|
72
87
|
"Connection": "Keep-Alive",
|
|
73
88
|
"Host": "api3x.firstrade.com",
|
|
@@ -76,5 +91,6 @@ def session_headers():
|
|
|
76
91
|
return headers
|
|
77
92
|
|
|
78
93
|
|
|
79
|
-
def access_token():
|
|
94
|
+
def access_token() -> str:
|
|
95
|
+
"""Access token for FirstTrade API."""
|
|
80
96
|
return "833w3XuIFycv18ybi"
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: firstrade
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.37
|
|
4
4
|
Summary: An unofficial API for Firstrade
|
|
5
5
|
Home-page: https://github.com/MaxxRK/firstrade-api
|
|
6
|
-
Download-URL: https://github.com/MaxxRK/firstrade-api/archive/refs/tags/
|
|
6
|
+
Download-URL: https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0037.tar.gz
|
|
7
7
|
Author: MaxxRK
|
|
8
8
|
Author-email: maxxrk@pm.me
|
|
9
9
|
License: MIT
|
|
@@ -13,8 +13,6 @@ Classifier: Intended Audience :: Developers
|
|
|
13
13
|
Classifier: Topic :: Internet :: WWW/HTTP :: Session
|
|
14
14
|
Classifier: License :: OSI Approved :: MIT License
|
|
15
15
|
Classifier: Programming Language :: Python :: 3
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
18
16
|
Classifier: Programming Language :: Python :: 3.10
|
|
19
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -22,8 +20,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
22
20
|
Description-Content-Type: text/markdown
|
|
23
21
|
License-File: LICENSE
|
|
24
22
|
Requires-Dist: requests
|
|
25
|
-
Requires-Dist:
|
|
26
|
-
Requires-Dist: lxml
|
|
23
|
+
Requires-Dist: pyotp
|
|
27
24
|
Dynamic: author
|
|
28
25
|
Dynamic: author-email
|
|
29
26
|
Dynamic: classifier
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
firstrade/__init__.py,sha256=fNiWYgSTjElY1MNv0Ug-sVLMTR2z_Ngri_FY7Pekdrw,95
|
|
2
|
+
firstrade/account.py,sha256=U_-4Kg0veehd1-wo_KHNmScfD_xLFTilFZjIWUlByQM,19270
|
|
3
|
+
firstrade/exceptions.py,sha256=17speAxfarT1U-k_HWvfUanpUk6U2bCZ2ag49MdWbw0,1928
|
|
4
|
+
firstrade/order.py,sha256=K1-mpvu2ooUBxNtXkX_H8mdJMoRiUG1JDqsmpzfdFBQ,8530
|
|
5
|
+
firstrade/symbols.py,sha256=v6ejLCLo3L_JzPRN3B2yMYn-5qBVCRG-3cx6Y7IMrb0,7466
|
|
6
|
+
firstrade/urls.py,sha256=atalvLvevb1CAOBOdUIX3HAebw3TSRuIhCYFhSXIbQ8,3294
|
|
7
|
+
firstrade-0.0.37.dist-info/licenses/LICENSE,sha256=wPEQjDqm5zMBmEcZp219Labmq_YIjhudpZiUzyVKaFA,1057
|
|
8
|
+
firstrade-0.0.37.dist-info/METADATA,sha256=kpZfe0iUMNulPdNO0THdcOeRYFJBA6nKLsSGU7vaTr8,3238
|
|
9
|
+
firstrade-0.0.37.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
10
|
+
firstrade-0.0.37.dist-info/top_level.txt,sha256=tdA8v-KDxU1u4VV6soiNWGBlni4ojv_t_j2wFn5nZcs,10
|
|
11
|
+
firstrade-0.0.37.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
firstrade/__init__.py,sha256=fNiWYgSTjElY1MNv0Ug-sVLMTR2z_Ngri_FY7Pekdrw,95
|
|
2
|
-
firstrade/account.py,sha256=4tS-m_85RW6E5YM6Srn9Wn-6TJKV0fDZYnMId7Rb5B4,15870
|
|
3
|
-
firstrade/exceptions.py,sha256=OrWB83rc33LSxrI7WxXo4o7FcIfmvPSC9bAY8K1pn7U,1886
|
|
4
|
-
firstrade/order.py,sha256=_b1SnqagwBu7KUmvzSUcp8iMOC3I3k-QDjiDLhlVk7E,8710
|
|
5
|
-
firstrade/symbols.py,sha256=RH36QLx5v3rKPpBidyJFwGJSDkF5u5f2ILTSZNAGXGQ,7903
|
|
6
|
-
firstrade/urls.py,sha256=Iw10isyvoqKwiSl3TVuIbos5INZzIEwpln3HcZ7P5aw,2125
|
|
7
|
-
firstrade-0.0.34.dist-info/licenses/LICENSE,sha256=wPEQjDqm5zMBmEcZp219Labmq_YIjhudpZiUzyVKaFA,1057
|
|
8
|
-
firstrade-0.0.34.dist-info/METADATA,sha256=cEo6oLxI6nGGQGTOM1wAfISYjt-5jZ1B_WirQsalSdc,3367
|
|
9
|
-
firstrade-0.0.34.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
-
firstrade-0.0.34.dist-info/top_level.txt,sha256=tdA8v-KDxU1u4VV6soiNWGBlni4ojv_t_j2wFn5nZcs,10
|
|
11
|
-
firstrade-0.0.34.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|