firstrade 0.0.35__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 +248 -197
- firstrade/exceptions.py +3 -4
- firstrade/order.py +41 -45
- firstrade/symbols.py +48 -58
- firstrade/urls.py +38 -22
- {firstrade-0.0.35.dist-info → firstrade-0.0.37.dist-info}/METADATA +2 -2
- firstrade-0.0.37.dist-info/RECORD +11 -0
- firstrade-0.0.35.dist-info/RECORD +0 -11
- {firstrade-0.0.35.dist-info → firstrade-0.0.37.dist-info}/WHEEL +0 -0
- {firstrade-0.0.35.dist-info → firstrade-0.0.37.dist-info}/licenses/LICENSE +0 -0
- {firstrade-0.0.35.dist-info → firstrade-0.0.37.dist-info}/top_level.txt +0 -0
firstrade/account.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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
|
|
@@ -13,10 +13,10 @@ from firstrade.exceptions import (
|
|
|
13
13
|
LoginResponseError,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
16
17
|
|
|
17
18
|
class FTSession:
|
|
18
|
-
"""
|
|
19
|
-
Class creating a session for Firstrade.
|
|
19
|
+
"""Class creating a session for Firstrade.
|
|
20
20
|
|
|
21
21
|
This class handles the creation and management of a session for logging into the Firstrade platform.
|
|
22
22
|
It supports multi-factor authentication (MFA) and can save session cookies for persistent logins.
|
|
@@ -24,18 +24,19 @@ class FTSession:
|
|
|
24
24
|
Attributes:
|
|
25
25
|
username (str): Firstrade login username.
|
|
26
26
|
password (str): Firstrade login password.
|
|
27
|
-
pin (str): Firstrade login pin.
|
|
27
|
+
pin (str, optional): Firstrade login pin.
|
|
28
28
|
email (str, optional): Firstrade MFA email.
|
|
29
29
|
phone (str, optional): Firstrade MFA phone number.
|
|
30
30
|
mfa_secret (str, optional): Secret key for generating MFA codes.
|
|
31
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.
|
|
32
33
|
t_token (str, optional): Token used for MFA.
|
|
33
34
|
otp_options (dict, optional): Options for OTP (One-Time Password) if MFA is enabled.
|
|
34
35
|
login_json (dict, optional): JSON response from the login request.
|
|
35
36
|
session (requests.Session): The requests session object used for making HTTP requests.
|
|
36
37
|
|
|
37
38
|
Methods:
|
|
38
|
-
__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):
|
|
39
40
|
Initializes a new instance of the FTSession class.
|
|
40
41
|
login():
|
|
41
42
|
Validates and logs into the Firstrade platform.
|
|
@@ -51,44 +52,58 @@ class FTSession:
|
|
|
51
52
|
Masks the email for use in the API.
|
|
52
53
|
_handle_mfa():
|
|
53
54
|
Handles multi-factor authentication.
|
|
55
|
+
_request(method, url, **kwargs):
|
|
56
|
+
HTTP requests wrapper to the API.
|
|
57
|
+
|
|
54
58
|
"""
|
|
55
59
|
|
|
56
60
|
def __init__(
|
|
57
61
|
self,
|
|
58
|
-
username,
|
|
59
|
-
password,
|
|
60
|
-
pin=
|
|
61
|
-
email=
|
|
62
|
-
phone=
|
|
63
|
-
mfa_secret=
|
|
64
|
-
profile_path=None,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
68
72
|
|
|
69
73
|
Args:
|
|
70
74
|
username (str): Firstrade login username.
|
|
71
75
|
password (str): Firstrade login password.
|
|
72
|
-
pin (str): Firstrade login pin.
|
|
76
|
+
pin (str, optional): Firstrade login pin.
|
|
73
77
|
email (str, optional): Firstrade MFA email.
|
|
74
78
|
phone (str, optional): Firstrade MFA phone number.
|
|
79
|
+
mfa_secret (str, optional): Firstrade MFA secret key to generate TOTP.
|
|
75
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
|
+
|
|
76
83
|
"""
|
|
77
|
-
self.username = username
|
|
78
|
-
self.password = password
|
|
79
|
-
self.pin = pin
|
|
80
|
-
self.email = FTSession._mask_email(email) if email
|
|
81
|
-
self.phone = phone
|
|
82
|
-
self.mfa_secret = mfa_secret
|
|
83
|
-
self.profile_path = profile_path
|
|
84
|
-
self.
|
|
85
|
-
self.
|
|
86
|
-
|
|
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] = {}
|
|
87
103
|
self.session = requests.Session()
|
|
88
104
|
|
|
89
|
-
def login(self):
|
|
90
|
-
"""
|
|
91
|
-
Validates and logs into the Firstrade platform.
|
|
105
|
+
def login(self) -> bool:
|
|
106
|
+
"""Validate and log into the Firstrade platform.
|
|
92
107
|
|
|
93
108
|
This method sets up the session headers, loads cookies if available, and performs the login request.
|
|
94
109
|
It handles multi-factor authentication (MFA) if required.
|
|
@@ -96,43 +111,42 @@ class FTSession:
|
|
|
96
111
|
Raises:
|
|
97
112
|
LoginRequestError: If the login request fails with a non-200 status code.
|
|
98
113
|
LoginResponseError: If the login response contains an error message.
|
|
114
|
+
|
|
99
115
|
"""
|
|
100
|
-
self.session.headers
|
|
101
|
-
ftat = self._load_cookies()
|
|
102
|
-
if ftat
|
|
116
|
+
self.session.headers.update(urls.session_headers())
|
|
117
|
+
ftat: str = self._load_cookies()
|
|
118
|
+
if ftat:
|
|
103
119
|
self.session.headers["ftat"] = ftat
|
|
104
|
-
response = self.
|
|
120
|
+
response: requests.Response = self._request("get", url="https://api3x.firstrade.com/", timeout=10)
|
|
105
121
|
self.session.headers["access-token"] = urls.access_token()
|
|
106
122
|
|
|
107
|
-
data = {
|
|
123
|
+
data: dict[str, str] = {
|
|
108
124
|
"username": r"" + self.username,
|
|
109
125
|
"password": r"" + self.password,
|
|
110
126
|
}
|
|
111
127
|
|
|
112
|
-
response = self.
|
|
128
|
+
response: requests.Response = self._request(
|
|
129
|
+
"post",
|
|
113
130
|
url=urls.login(),
|
|
114
131
|
data=data,
|
|
115
132
|
)
|
|
116
133
|
try:
|
|
117
|
-
self.login_json = response.json()
|
|
118
|
-
except json.decoder.JSONDecodeError:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
and "ftat" in self.login_json
|
|
123
|
-
and self.login_json["error"] == ""
|
|
124
|
-
):
|
|
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"]:
|
|
125
139
|
self.session.headers["sid"] = self.login_json["sid"]
|
|
126
140
|
return False
|
|
127
|
-
self.t_token = self.login_json.get("t_token")
|
|
128
|
-
if self.mfa_secret
|
|
129
|
-
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")
|
|
130
144
|
if response.status_code != 200:
|
|
131
145
|
raise LoginRequestError(response.status_code)
|
|
132
|
-
if self.login_json["error"]
|
|
146
|
+
if self.login_json["error"]:
|
|
133
147
|
raise LoginResponseError(self.login_json["error"])
|
|
134
|
-
need_code = self._handle_mfa()
|
|
135
|
-
if self.login_json["error"]
|
|
148
|
+
need_code: bool | None = self._handle_mfa()
|
|
149
|
+
if self.login_json["error"]:
|
|
136
150
|
raise LoginResponseError(self.login_json["error"])
|
|
137
151
|
if need_code:
|
|
138
152
|
return True
|
|
@@ -141,149 +155,183 @@ class FTSession:
|
|
|
141
155
|
self._save_cookies()
|
|
142
156
|
return False
|
|
143
157
|
|
|
144
|
-
def login_two(self, code):
|
|
145
|
-
"""
|
|
146
|
-
data = {
|
|
158
|
+
def login_two(self, code: str) -> None:
|
|
159
|
+
"""Finish login to the Firstrade platform."""
|
|
160
|
+
data: dict[str, str | None] = {
|
|
147
161
|
"otpCode": code,
|
|
148
162
|
"verificationSid": self.session.headers["sid"],
|
|
149
163
|
"remember_for": "30",
|
|
150
164
|
"t_token": self.t_token,
|
|
151
165
|
}
|
|
152
|
-
response = self.
|
|
153
|
-
self.login_json = response.json()
|
|
154
|
-
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"]:
|
|
155
169
|
raise LoginResponseError(self.login_json["error"])
|
|
156
170
|
self.session.headers["ftat"] = self.login_json["ftat"]
|
|
157
171
|
self.session.headers["sid"] = self.login_json["sid"]
|
|
158
172
|
self._save_cookies()
|
|
159
173
|
|
|
160
|
-
def delete_cookies(self):
|
|
161
|
-
"""
|
|
162
|
-
if self.profile_path is not None
|
|
163
|
-
|
|
164
|
-
else:
|
|
165
|
-
path = f"ft_cookies{self.username}.pkl"
|
|
166
|
-
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()
|
|
167
178
|
|
|
168
|
-
def _load_cookies(self):
|
|
169
|
-
"""
|
|
170
|
-
Checks if session cookies were saved.
|
|
179
|
+
def _load_cookies(self) -> str:
|
|
180
|
+
"""Check if session cookies were saved.
|
|
171
181
|
|
|
172
182
|
Returns:
|
|
173
183
|
str: The saved session token.
|
|
174
|
-
"""
|
|
175
184
|
|
|
185
|
+
"""
|
|
176
186
|
ftat = ""
|
|
177
|
-
directory = (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
filepath = os.path.join(directory, filename)
|
|
186
|
-
with open(filepath, "rb") as f:
|
|
187
|
-
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)
|
|
188
195
|
return ftat
|
|
189
196
|
|
|
190
|
-
def _save_cookies(self):
|
|
191
|
-
"""
|
|
197
|
+
def _save_cookies(self) -> str | None:
|
|
198
|
+
"""Save session cookies to a file."""
|
|
192
199
|
if self.profile_path is not None:
|
|
193
|
-
directory =
|
|
194
|
-
if not
|
|
195
|
-
|
|
196
|
-
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"
|
|
197
204
|
else:
|
|
198
|
-
path = f"ft_cookies{self.username}.
|
|
199
|
-
with open(
|
|
200
|
-
ftat = self.session.headers.get("ftat")
|
|
201
|
-
|
|
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)
|
|
202
209
|
|
|
203
210
|
@staticmethod
|
|
204
|
-
def _mask_email(email):
|
|
205
|
-
"""
|
|
206
|
-
Masks the email for use in the API.
|
|
211
|
+
def _mask_email(email: str) -> str:
|
|
212
|
+
"""Mask the email for use in the API.
|
|
207
213
|
|
|
208
214
|
Args:
|
|
209
215
|
email (str): The email address to be masked.
|
|
210
216
|
|
|
211
217
|
Returns:
|
|
212
218
|
str: The masked email address.
|
|
219
|
+
|
|
213
220
|
"""
|
|
214
|
-
local, domain = email.split("@")
|
|
215
|
-
masked_local = local[0] + "*" * 4
|
|
221
|
+
local, domain = email.split(sep="@")
|
|
222
|
+
masked_local: str = local[0] + "*" * 4
|
|
216
223
|
domain_name, tld = domain.split(".")
|
|
217
|
-
masked_domain = domain_name[0] + "*" * 4
|
|
224
|
+
masked_domain: str = domain_name[0] + "*" * 4
|
|
218
225
|
return f"{masked_local}@{masked_domain}.{tld}"
|
|
219
226
|
|
|
220
|
-
def _handle_mfa(self):
|
|
221
|
-
"""
|
|
222
|
-
Handles multi-factor authentication.
|
|
227
|
+
def _handle_mfa(self) -> bool:
|
|
228
|
+
"""Handle multi-factor authentication.
|
|
223
229
|
|
|
224
230
|
This method processes the MFA requirements based on the login response and user-provided details.
|
|
225
231
|
|
|
226
|
-
Raises:
|
|
227
|
-
LoginRequestError: If the MFA request fails with a non-200 status code.
|
|
228
|
-
LoginResponseError: If the MFA response contains an error message.
|
|
229
232
|
"""
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
response = self.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
self.email is not None or self.phone is not None
|
|
240
|
-
):
|
|
241
|
-
for item in self.otp_options:
|
|
242
|
-
if item["channel"] == "sms" and self.phone is not None:
|
|
243
|
-
if self.phone in item["recipientMask"]:
|
|
244
|
-
data = {
|
|
245
|
-
"recipientId": item["recipientId"],
|
|
246
|
-
"t_token": self.t_token,
|
|
247
|
-
}
|
|
248
|
-
break
|
|
249
|
-
elif item["channel"] == "email" and self.email is not None:
|
|
250
|
-
if self.email == item["recipientMask"]:
|
|
251
|
-
data = {
|
|
252
|
-
"recipientId": item["recipientId"],
|
|
253
|
-
"t_token": self.t_token,
|
|
254
|
-
}
|
|
255
|
-
break
|
|
256
|
-
response = self.session.post(urls.request_code(), data=data)
|
|
257
|
-
elif self.login_json["mfa"] and self.mfa_secret is not None:
|
|
258
|
-
mfa_otp = pyotp.TOTP(self.mfa_secret).now()
|
|
259
|
-
data = {
|
|
260
|
-
"mfaCode": mfa_otp,
|
|
261
|
-
"remember_for": "30",
|
|
262
|
-
"t_token": self.t_token,
|
|
263
|
-
}
|
|
264
|
-
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)
|
|
265
242
|
else:
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
)
|
|
243
|
+
error_msg = "MFA required but no valid MFA method was provided (pin, email/phone, or mfa_secret)."
|
|
244
|
+
raise LoginError(error_msg)
|
|
245
|
+
|
|
270
246
|
self.login_json = response.json()
|
|
271
|
-
if self.login_json["error"]
|
|
272
|
-
|
|
273
|
-
self.session.headers["sid"] = self.login_json["sid"]
|
|
274
|
-
return False
|
|
275
|
-
self.session.headers["sid"] = self.login_json["verificationSid"]
|
|
276
|
-
return True
|
|
247
|
+
if self.login_json["error"]:
|
|
248
|
+
raise LoginResponseError(self.login_json["error"])
|
|
277
249
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
281
328
|
|
|
282
329
|
Args:
|
|
283
330
|
name (str): The name of the attribute to be accessed.
|
|
284
331
|
|
|
285
332
|
Returns:
|
|
286
333
|
The value of the requested attribute from the session object.
|
|
334
|
+
|
|
287
335
|
"""
|
|
288
336
|
return getattr(self.session, name)
|
|
289
337
|
|
|
@@ -291,21 +339,20 @@ class FTSession:
|
|
|
291
339
|
class FTAccountData:
|
|
292
340
|
"""Dataclass for storing account information."""
|
|
293
341
|
|
|
294
|
-
def __init__(self, session):
|
|
295
|
-
"""
|
|
296
|
-
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.
|
|
297
344
|
|
|
298
345
|
Args:
|
|
299
|
-
session (requests.Session):
|
|
300
|
-
|
|
346
|
+
session (requests.Session): The session object used for making HTTP requests.
|
|
347
|
+
|
|
301
348
|
"""
|
|
302
|
-
self.session = session
|
|
303
|
-
self.all_accounts = []
|
|
304
|
-
self.account_numbers = []
|
|
305
|
-
self.account_balances = {}
|
|
306
|
-
response = self.session.get
|
|
307
|
-
self.user_info = response.json()
|
|
308
|
-
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())
|
|
309
356
|
if response.status_code != 200 or response.json()["error"] != "":
|
|
310
357
|
raise AccountResponseError(response.json()["error"])
|
|
311
358
|
self.all_accounts = response.json()
|
|
@@ -313,90 +360,93 @@ class FTAccountData:
|
|
|
313
360
|
self.account_numbers.append(item["account"])
|
|
314
361
|
self.account_balances[item["account"]] = item["total_value"]
|
|
315
362
|
|
|
316
|
-
def get_account_balances(self, account):
|
|
317
|
-
"""
|
|
363
|
+
def get_account_balances(self, account: str) -> dict[str, object]:
|
|
364
|
+
"""Get account balances for a given account.
|
|
318
365
|
|
|
319
366
|
Args:
|
|
320
367
|
account (str): Account number of the account you want to get balances for.
|
|
321
368
|
|
|
322
369
|
Returns:
|
|
323
370
|
dict: Dict of the response from the API.
|
|
371
|
+
|
|
324
372
|
"""
|
|
325
|
-
response = self.session.get
|
|
373
|
+
response: requests.Response = self.session._request("get", urls.account_balances(account))
|
|
326
374
|
return response.json()
|
|
327
375
|
|
|
328
|
-
def get_positions(self, account):
|
|
329
|
-
"""
|
|
376
|
+
def get_positions(self, account: str) -> dict[str, object]:
|
|
377
|
+
"""Get currently held positions for a given account.
|
|
330
378
|
|
|
331
379
|
Args:
|
|
332
380
|
account (str): Account number of the account you want to get positions for.
|
|
333
381
|
|
|
334
382
|
Returns:
|
|
335
383
|
dict: Dict of the response from the API.
|
|
336
|
-
"""
|
|
337
384
|
|
|
338
|
-
|
|
385
|
+
"""
|
|
386
|
+
response = self.session._request("get", urls.account_positions(account))
|
|
339
387
|
return response.json()
|
|
340
388
|
|
|
341
|
-
def get_account_history(
|
|
342
|
-
|
|
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.
|
|
343
396
|
|
|
344
397
|
Args:
|
|
345
398
|
account (str): Account number of the account you want to get history for.
|
|
346
|
-
|
|
399
|
+
date_range (str): The range of the history. Defaults to "ytd".
|
|
347
400
|
Available options are
|
|
348
401
|
["today", "1w", "1m", "2m", "mtd", "ytd", "ly", "cust"].
|
|
349
|
-
custom_range (str): The custom range of the history.
|
|
402
|
+
custom_range (list[str] | None): The custom range of the history.
|
|
350
403
|
Defaults to None. If range is "cust",
|
|
351
404
|
this parameter is required.
|
|
352
405
|
Format: ["YYYY-MM-DD", "YYYY-MM-DD"].
|
|
353
406
|
|
|
354
407
|
Returns:
|
|
355
408
|
dict: Dict of the response from the API.
|
|
409
|
+
|
|
356
410
|
"""
|
|
357
411
|
if date_range == "cust" and custom_range is None:
|
|
358
|
-
raise ValueError("Custom range
|
|
359
|
-
response = self.session.
|
|
360
|
-
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),
|
|
361
415
|
)
|
|
362
416
|
return response.json()
|
|
363
417
|
|
|
364
|
-
def get_orders(self, account):
|
|
365
|
-
"""
|
|
366
|
-
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.
|
|
367
420
|
|
|
368
421
|
Args:
|
|
369
|
-
ft_session (FTSession): The session object used for making HTTP requests to Firstrade.
|
|
370
422
|
account (str): Account number of the account to retrieve orders for.
|
|
371
423
|
|
|
372
424
|
Returns:
|
|
373
425
|
list: A list of dictionaries, each containing details about an order.
|
|
374
|
-
"""
|
|
375
426
|
|
|
376
|
-
|
|
427
|
+
"""
|
|
428
|
+
response = self.session._request("get", url=urls.order_list(account))
|
|
377
429
|
return response.json()
|
|
378
430
|
|
|
379
|
-
def cancel_order(self, order_id):
|
|
380
|
-
"""
|
|
381
|
-
Cancels an existing order.
|
|
431
|
+
def cancel_order(self, order_id: str) -> dict[str, object]:
|
|
432
|
+
"""Cancel an existing order.
|
|
382
433
|
|
|
383
434
|
Args:
|
|
384
435
|
order_id (str): The order ID to cancel.
|
|
385
436
|
|
|
386
437
|
Returns:
|
|
387
438
|
dict: A dictionary containing the response data.
|
|
388
|
-
"""
|
|
389
439
|
|
|
440
|
+
"""
|
|
390
441
|
data = {
|
|
391
442
|
"order_id": order_id,
|
|
392
443
|
}
|
|
393
444
|
|
|
394
|
-
response = self.session.post
|
|
445
|
+
response = self.session._request("post", url=urls.cancel_order(), data=data)
|
|
395
446
|
return response.json()
|
|
396
447
|
|
|
397
|
-
def get_balance_overview(self, account, keywords=None):
|
|
398
|
-
"""
|
|
399
|
-
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.
|
|
400
450
|
|
|
401
451
|
This is a convenience helper over `get_account_balances` to quickly
|
|
402
452
|
surface likely relevant numbers such as cash, available cash, and
|
|
@@ -410,6 +460,7 @@ class FTAccountData:
|
|
|
410
460
|
Returns:
|
|
411
461
|
dict: A dict mapping dot-notated keys to values from the balances
|
|
412
462
|
response where the key path contains any of the keywords.
|
|
463
|
+
|
|
413
464
|
"""
|
|
414
465
|
if keywords is None:
|
|
415
466
|
keywords = [
|
|
@@ -423,22 +474,22 @@ class FTAccountData:
|
|
|
423
474
|
"margin",
|
|
424
475
|
]
|
|
425
476
|
|
|
426
|
-
payload = self.get_account_balances(account)
|
|
477
|
+
payload: dict[str, object] = self.get_account_balances(account)
|
|
427
478
|
|
|
428
|
-
filtered = {}
|
|
479
|
+
filtered: dict[str, object] = {}
|
|
429
480
|
|
|
430
|
-
def _walk(node, path):
|
|
481
|
+
def _walk(node: object, path: list[str]) -> None:
|
|
431
482
|
if isinstance(node, dict):
|
|
432
483
|
for k, v in node.items():
|
|
433
|
-
_walk(v, path
|
|
484
|
+
_walk(node=v, path=[*path, str(object=k)])
|
|
434
485
|
elif isinstance(node, list):
|
|
435
|
-
for i, v in enumerate(node):
|
|
436
|
-
_walk(v, path
|
|
486
|
+
for i, v in enumerate(iterable=node):
|
|
487
|
+
_walk(node=v, path=[*path, str(object=i)])
|
|
437
488
|
else:
|
|
438
|
-
key_path = ".".join(path)
|
|
439
|
-
low = key_path.lower()
|
|
489
|
+
key_path: str = ".".join(path)
|
|
490
|
+
low: str = key_path.lower()
|
|
440
491
|
if any(sub in low for sub in keywords):
|
|
441
492
|
filtered[key_path] = node
|
|
442
493
|
|
|
443
|
-
_walk(payload, [])
|
|
494
|
+
_walk(node=payload, path=[])
|
|
444
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
|
|
@@ -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=1SMh3fopaHJnyTtHFsbuHXihz7qqv9m8OReDZ3rk-XM,16066
|
|
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.35.dist-info/licenses/LICENSE,sha256=wPEQjDqm5zMBmEcZp219Labmq_YIjhudpZiUzyVKaFA,1057
|
|
8
|
-
firstrade-0.0.35.dist-info/METADATA,sha256=6nQJDoEBpQBlai_7wzwZiWMqm5r8VWMoL21_2mNdH8M,3238
|
|
9
|
-
firstrade-0.0.35.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
10
|
-
firstrade-0.0.35.dist-info/top_level.txt,sha256=tdA8v-KDxU1u4VV6soiNWGBlni4ojv_t_j2wFn5nZcs,10
|
|
11
|
-
firstrade-0.0.35.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|