fidelity-api 0.0.1__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.
fidelity/fidelity.py
ADDED
|
@@ -0,0 +1,1373 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import traceback
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pyotp
|
|
6
|
+
import typing
|
|
7
|
+
from typing import Literal
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
|
|
11
|
+
from playwright_stealth import StealthConfig, stealth_sync
|
|
12
|
+
import csv
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
# Needed for the download_prev_statement function
|
|
16
|
+
class fid_months(Enum):
|
|
17
|
+
"""
|
|
18
|
+
Months that fidelity uses in the statement labeling
|
|
19
|
+
"""
|
|
20
|
+
Jan = 1
|
|
21
|
+
Feb = 2
|
|
22
|
+
March = 3
|
|
23
|
+
April = 4
|
|
24
|
+
May = 5
|
|
25
|
+
June = 6
|
|
26
|
+
July = 7
|
|
27
|
+
Aug = 8
|
|
28
|
+
Sep = 9
|
|
29
|
+
Oct = 10
|
|
30
|
+
Nov = 11
|
|
31
|
+
Dec = 12
|
|
32
|
+
|
|
33
|
+
class FidelityAutomation:
|
|
34
|
+
"""
|
|
35
|
+
A class to manage and control a playwright webdriver with Fidelity.
|
|
36
|
+
If you have multiple login sets and want to use cookies, make sure "title" is unique each time you create this class,
|
|
37
|
+
otherwise the cookies will be overwritten each time.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
headless (bool)
|
|
42
|
+
If False the browser will be headless.
|
|
43
|
+
debug (bool)
|
|
44
|
+
If the driver should print debug info.
|
|
45
|
+
title (str)
|
|
46
|
+
The title of this session. Used for cookies file is present.
|
|
47
|
+
source_account (str)
|
|
48
|
+
Account to use as the "From" account for transfers.
|
|
49
|
+
save_state (bool)
|
|
50
|
+
Determine whether to save cookies in a json file.
|
|
51
|
+
profile_path (str)
|
|
52
|
+
Path used to store browser session data.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, headless: bool = True, debug: bool = False, title: str = None, source_account: str = None, save_state: bool = True, profile_path: str = ".") -> None:
|
|
57
|
+
"""
|
|
58
|
+
Setup the class, create the driver, and apply stealth settings.
|
|
59
|
+
"""
|
|
60
|
+
# Setup the webdriver
|
|
61
|
+
self.headless: bool = headless
|
|
62
|
+
self.title: str = title
|
|
63
|
+
self.save_state: bool = save_state
|
|
64
|
+
self.debug = debug
|
|
65
|
+
self.profile_path: str = profile_path
|
|
66
|
+
self.stealth_config = StealthConfig(
|
|
67
|
+
navigator_languages=False,
|
|
68
|
+
navigator_user_agent=False,
|
|
69
|
+
navigator_vendor=False,
|
|
70
|
+
)
|
|
71
|
+
self.getDriver()
|
|
72
|
+
# Some class variables
|
|
73
|
+
self.account_dict: dict = {}
|
|
74
|
+
self.source_account = source_account
|
|
75
|
+
self.new_account_number = None
|
|
76
|
+
|
|
77
|
+
def getDriver(self):
|
|
78
|
+
"""
|
|
79
|
+
Initializes the playwright webdriver for use in subsequent functions.
|
|
80
|
+
Creates and applies stealth settings to playwright context wrapper.
|
|
81
|
+
If self.save_state is set to True, create a storage path for cookies and data
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
None
|
|
86
|
+
"""
|
|
87
|
+
# Set the context wrapper
|
|
88
|
+
self.playwright = sync_playwright().start()
|
|
89
|
+
|
|
90
|
+
# Create or load cookies if save_state is set
|
|
91
|
+
if self.save_state:
|
|
92
|
+
self.profile_path = os.path.abspath(self.profile_path)
|
|
93
|
+
# If title was given
|
|
94
|
+
if self.title is not None:
|
|
95
|
+
# Use the title for the json file
|
|
96
|
+
self.profile_path = os.path.join(
|
|
97
|
+
self.profile_path, f"Fidelity_{self.title}.json"
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
# Use default name for json file
|
|
101
|
+
self.profile_path = os.path.join(self.profile_path, "Fidelity.json")
|
|
102
|
+
# If the path supplied doesn't exist, make it
|
|
103
|
+
if not os.path.exists(self.profile_path):
|
|
104
|
+
os.makedirs(os.path.dirname(self.profile_path), exist_ok=True)
|
|
105
|
+
with open(self.profile_path, "w") as f:
|
|
106
|
+
json.dump({}, f)
|
|
107
|
+
|
|
108
|
+
# Launch the browser
|
|
109
|
+
self.browser = self.playwright.firefox.launch(
|
|
110
|
+
headless=self.headless,
|
|
111
|
+
args=["--disable-webgl", "--disable-software-rasterizer"],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
self.context = self.browser.new_context(
|
|
115
|
+
storage_state=self.profile_path if self.title is not None else None
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Take screenshots on actions
|
|
119
|
+
if self.debug:
|
|
120
|
+
self.context.tracing.start(name="fidelity_trace", screenshots=True, snapshots=True)
|
|
121
|
+
|
|
122
|
+
self.page = self.context.new_page()
|
|
123
|
+
# Apply stealth settings
|
|
124
|
+
stealth_sync(self.page, self.stealth_config)
|
|
125
|
+
|
|
126
|
+
def get_list_of_accounts(self, set_flag: bool = True, get_withdrawal_bal: bool = False):
|
|
127
|
+
"""
|
|
128
|
+
Uses the transfers page's dropdown to obtain the list of accounts.
|
|
129
|
+
Separates the account number and nickname and places them into `self.account_dict`
|
|
130
|
+
if not already present
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
set_flag (bool) = True
|
|
135
|
+
If set_flag is false, `self.account_dict` will not be updated
|
|
136
|
+
get_withdrawal_bal (bool) = False
|
|
137
|
+
If set to true, the function will provide the available balance that can be withdrawn from the account
|
|
138
|
+
|
|
139
|
+
Post conditions
|
|
140
|
+
---------------
|
|
141
|
+
`self.account_dict` is updated with account numbers and nicknames if set_flag is True or omitted
|
|
142
|
+
|
|
143
|
+
Returns
|
|
144
|
+
-------
|
|
145
|
+
account_dict
|
|
146
|
+
A dictionary of the account information using account numbers as keys. See set_account_dict
|
|
147
|
+
for more info on how to use this dictionary.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
# Go to the transfers page
|
|
151
|
+
self.page.wait_for_load_state(state="load")
|
|
152
|
+
self.page.goto(url="https://digital.fidelity.com/ftgw/digital/transfer/?quicktransfer=cash-shares")
|
|
153
|
+
self.wait_for_loading_sign()
|
|
154
|
+
|
|
155
|
+
# Select the source account from the 'From' dropdown
|
|
156
|
+
from_select = self.page.get_by_label("From")
|
|
157
|
+
options = from_select.locator("option").all()
|
|
158
|
+
|
|
159
|
+
local_dict = {}
|
|
160
|
+
# Get account number and nickname
|
|
161
|
+
for option in options:
|
|
162
|
+
# Try to find accounts by using a regular expression
|
|
163
|
+
# This regex matches a string of numbers starting with a Z or a digit that
|
|
164
|
+
# has a '(' in front of it and a ')' at the end. Must have at least 6 digits after the
|
|
165
|
+
# Z or first digit.
|
|
166
|
+
account_number = re.search(r'(?<=\()(Z|\d)\d{6,}(?=\))', option.inner_text())
|
|
167
|
+
nickname = re.search(r'^.+?(?=\()', option.inner_text())
|
|
168
|
+
with_bal = None
|
|
169
|
+
|
|
170
|
+
# Get withdrawal balance once we find a valid account
|
|
171
|
+
if get_withdrawal_bal and account_number and nickname:
|
|
172
|
+
# Select the account in the dropdown
|
|
173
|
+
acc_drpdwn_value = option.get_attribute("value")
|
|
174
|
+
from_select.select_option(acc_drpdwn_value)
|
|
175
|
+
# Wait for balance info to update. This is very fast but there is a delay
|
|
176
|
+
self.page.wait_for_timeout(100)
|
|
177
|
+
# Find the balance
|
|
178
|
+
with_bal = self.page.locator("tr.pvd-table__row:nth-child(2) > td:nth-child(2)").inner_text()
|
|
179
|
+
with_bal = float(with_bal.replace("$", "").replace(",", ""))
|
|
180
|
+
|
|
181
|
+
# Add to the account dict
|
|
182
|
+
if set_flag and account_number and nickname:
|
|
183
|
+
# Create entry if not already there
|
|
184
|
+
if not self.set_account_dict(
|
|
185
|
+
account_num=account_number.group(0),
|
|
186
|
+
nickname=nickname.group(0),
|
|
187
|
+
withdrawal_balance=with_bal if with_bal is not None else 0.0
|
|
188
|
+
):
|
|
189
|
+
# If entry exists, overwrite withdrawal balance
|
|
190
|
+
self.add_withdrawal_bal_to_account_dict(
|
|
191
|
+
account_num=account_number.group(0),
|
|
192
|
+
withdrawal_balance=with_bal if with_bal is not None else 0.0,
|
|
193
|
+
overwrite=True
|
|
194
|
+
)
|
|
195
|
+
# Same with nickname
|
|
196
|
+
self.add_nickname_to_account_dict(
|
|
197
|
+
account_num=account_number.group(0),
|
|
198
|
+
nickname=nickname.group(0),
|
|
199
|
+
overwrite=True
|
|
200
|
+
)
|
|
201
|
+
# Or to local copy
|
|
202
|
+
elif not set_flag and account_number and nickname:
|
|
203
|
+
local_dict[account_number.group(0)] = {
|
|
204
|
+
"balance": 0.0,
|
|
205
|
+
"withdrawal_balance": with_bal if with_bal is not None else 0.0,
|
|
206
|
+
"nickname": nickname.group(0),
|
|
207
|
+
"stocks": []
|
|
208
|
+
}
|
|
209
|
+
if not set_flag:
|
|
210
|
+
return local_dict
|
|
211
|
+
|
|
212
|
+
return self.account_dict
|
|
213
|
+
|
|
214
|
+
except Exception as e:
|
|
215
|
+
print(f"An error occurred in get_list_of_accounts: {str(e)}")
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
def get_stocks_in_account(self, account_number: str) -> dict:
|
|
219
|
+
"""
|
|
220
|
+
`self.getAccountInfo() must be called before this to work
|
|
221
|
+
|
|
222
|
+
Returns
|
|
223
|
+
-------
|
|
224
|
+
all_stock_dict (dict)
|
|
225
|
+
A dict of stocks that the account has.
|
|
226
|
+
"""
|
|
227
|
+
if account_number in self.account_dict:
|
|
228
|
+
all_stock_dict = {}
|
|
229
|
+
for single_stock_dict in self.account_dict[account_number]["stocks"]:
|
|
230
|
+
stock = single_stock_dict.get("ticker", None)
|
|
231
|
+
quantity = single_stock_dict.get("quantity", None)
|
|
232
|
+
if stock is not None and quantity is not None:
|
|
233
|
+
all_stock_dict[stock] = quantity
|
|
234
|
+
|
|
235
|
+
return all_stock_dict
|
|
236
|
+
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
def getAccountInfo(self):
|
|
240
|
+
"""
|
|
241
|
+
Gets account numbers, account names, and account totals by downloading the csv of positions
|
|
242
|
+
from fidelity.
|
|
243
|
+
`Note` This will miss accounts that have no holdings! The positions csv doesn't show accounts
|
|
244
|
+
with only pending activity either. Use `self.get_list_of_accounts` for a full list of accounts.
|
|
245
|
+
|
|
246
|
+
Post Conditions:
|
|
247
|
+
self.account_dict is populated with holdings for each account
|
|
248
|
+
|
|
249
|
+
Returns
|
|
250
|
+
-------
|
|
251
|
+
account_dict (dict)
|
|
252
|
+
A dictionary using account numbers as keys. Each key holds a dict which has:
|
|
253
|
+
```
|
|
254
|
+
{
|
|
255
|
+
'balance': float: Total account balance
|
|
256
|
+
'type': str: The account nickname or default name
|
|
257
|
+
'stocks': list: A list of dictionaries for each stock found. The dict has:
|
|
258
|
+
{
|
|
259
|
+
'ticker': str: The ticker of the stock held
|
|
260
|
+
'quantity': str: The quantity of stocks with 'ticker' held
|
|
261
|
+
'last_price': str: The last price of the stock with the $ sign removed
|
|
262
|
+
'value': str: The total value of the position
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
"""
|
|
267
|
+
# Go to positions page
|
|
268
|
+
self.page.wait_for_load_state(state="load")
|
|
269
|
+
self.page.goto("https://digital.fidelity.com/ftgw/digital/portfolio/positions")
|
|
270
|
+
self.wait_for_loading_sign()
|
|
271
|
+
|
|
272
|
+
# Download the positions as a csv
|
|
273
|
+
with self.page.expect_download() as download_info:
|
|
274
|
+
self.page.get_by_label("Download Positions").click()
|
|
275
|
+
download = download_info.value
|
|
276
|
+
cur = os.getcwd()
|
|
277
|
+
positions_csv = os.path.join(cur, download.suggested_filename)
|
|
278
|
+
# Create a copy to work on with the proper file name known
|
|
279
|
+
download.save_as(positions_csv)
|
|
280
|
+
|
|
281
|
+
csv_file = open(positions_csv, newline="", encoding="utf-8-sig")
|
|
282
|
+
|
|
283
|
+
reader = csv.DictReader(csv_file)
|
|
284
|
+
# Ensure all fields we want are present
|
|
285
|
+
required_elements = [
|
|
286
|
+
"Account Number",
|
|
287
|
+
"Account Name",
|
|
288
|
+
"Symbol",
|
|
289
|
+
"Description",
|
|
290
|
+
"Quantity",
|
|
291
|
+
"Last Price",
|
|
292
|
+
"Current Value",
|
|
293
|
+
]
|
|
294
|
+
intersection_set = set(reader.fieldnames).intersection(set(required_elements))
|
|
295
|
+
if len(intersection_set) != len(required_elements):
|
|
296
|
+
raise Exception("Not enough elements in fidelity positions csv")
|
|
297
|
+
|
|
298
|
+
for row in reader:
|
|
299
|
+
# Skip empty rows
|
|
300
|
+
if row["Account Number"] is None:
|
|
301
|
+
continue
|
|
302
|
+
# Last couple of rows have some disclaimers, filter those out
|
|
303
|
+
if "and" in row["Account Number"]:
|
|
304
|
+
break
|
|
305
|
+
# Skip accounts that start with 'Y' (Fidelity managed)
|
|
306
|
+
if row["Account Number"][0] == "Y":
|
|
307
|
+
continue
|
|
308
|
+
# Get the value and remove '$' from it
|
|
309
|
+
val = str(row["Current Value"]).replace("$", "").replace("-", "")
|
|
310
|
+
# Get the last price
|
|
311
|
+
last_price = str(row["Last Price"]).replace("$", "").replace("-", "")
|
|
312
|
+
# Get quantity
|
|
313
|
+
quantity = str(row["Quantity"]).replace("-", "")
|
|
314
|
+
# Get ticker
|
|
315
|
+
ticker = str(row["Symbol"])
|
|
316
|
+
|
|
317
|
+
# Don't include this if present
|
|
318
|
+
if "Pending" in ticker:
|
|
319
|
+
continue
|
|
320
|
+
# If the value isn't present, move to next row
|
|
321
|
+
if len(val) == 0:
|
|
322
|
+
continue
|
|
323
|
+
# If the last price isn't available, just use the current value
|
|
324
|
+
if len(last_price) == 0:
|
|
325
|
+
last_price = val
|
|
326
|
+
# If the quantity is missing set it to 1 (For SPAXX or any other cash position)
|
|
327
|
+
if len(quantity) == 0:
|
|
328
|
+
quantity = 1
|
|
329
|
+
|
|
330
|
+
# Check for anything that isn't a number
|
|
331
|
+
try:
|
|
332
|
+
float(val)
|
|
333
|
+
except ValueError:
|
|
334
|
+
val = 0
|
|
335
|
+
try:
|
|
336
|
+
float(last_price)
|
|
337
|
+
except ValueError:
|
|
338
|
+
last_price = 0
|
|
339
|
+
try:
|
|
340
|
+
float(quantity)
|
|
341
|
+
except ValueError:
|
|
342
|
+
quantity = 0
|
|
343
|
+
|
|
344
|
+
# Create list of dictionary for stock found
|
|
345
|
+
stock_list = [create_stock_dict(ticker, float(quantity), float(last_price), float(val))]
|
|
346
|
+
# Try setting in the account dict without overwrite
|
|
347
|
+
if not self.set_account_dict(
|
|
348
|
+
account_num=row["Account Number"],
|
|
349
|
+
balance=float(val),
|
|
350
|
+
nickname=row["Account Name"],
|
|
351
|
+
stocks=stock_list,
|
|
352
|
+
overwrite=False,
|
|
353
|
+
):
|
|
354
|
+
# If the account exists already, add to it
|
|
355
|
+
self.add_stock_to_account_dict(row["Account Number"], stock_list[0])
|
|
356
|
+
|
|
357
|
+
# Close the file
|
|
358
|
+
csv_file.close()
|
|
359
|
+
# Delete the file
|
|
360
|
+
os.remove(positions_csv)
|
|
361
|
+
|
|
362
|
+
return self.account_dict
|
|
363
|
+
|
|
364
|
+
def set_account_dict(self, account_num: str, balance: float = None, withdrawal_balance: float = None, nickname: str = None, stocks: list = None, overwrite: bool = False):
|
|
365
|
+
"""
|
|
366
|
+
Create or rewrite (if overwrite=True) an entry in the account_dict.
|
|
367
|
+
The dictionary is keyed with account numbers such that:
|
|
368
|
+
```
|
|
369
|
+
account_dict["12345678"] =
|
|
370
|
+
{
|
|
371
|
+
"balance": balance if balance is not None else 0.0,
|
|
372
|
+
"withdrawal_balance": withdrawal_balance if withdrawal_balance is not None else 0.0,
|
|
373
|
+
"nickname": nickname,
|
|
374
|
+
"stocks": stocks if stocks is not None else []
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Parameters
|
|
379
|
+
----------
|
|
380
|
+
account_num (str)
|
|
381
|
+
The account number of a Fidelity account with no parenthesis. Ex: Z12345678
|
|
382
|
+
balance (float)
|
|
383
|
+
The balance of the account if present.
|
|
384
|
+
withdrawal_balance (float)
|
|
385
|
+
The available balance that can be withdrawn from the account as cash
|
|
386
|
+
nickname (str)
|
|
387
|
+
The nickname of the account. Ex: Individual
|
|
388
|
+
stocks (list)
|
|
389
|
+
A list of dictionaries that contain stock info. Each dictionary is defined as:
|
|
390
|
+
```
|
|
391
|
+
{
|
|
392
|
+
'ticker': str,
|
|
393
|
+
'quantity': float,
|
|
394
|
+
'last_price': float,
|
|
395
|
+
'value': float
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
overwrite (bool)
|
|
399
|
+
Whether to overwrite an existing entry if found.
|
|
400
|
+
|
|
401
|
+
Returns
|
|
402
|
+
-------
|
|
403
|
+
True
|
|
404
|
+
If successful
|
|
405
|
+
|
|
406
|
+
False
|
|
407
|
+
If entry exists and overwrite=False or stock list is incorrect
|
|
408
|
+
"""
|
|
409
|
+
# Overwrite or create new entry
|
|
410
|
+
if overwrite or account_num not in self.account_dict:
|
|
411
|
+
# Check stocks first. This returns true is stocks is None
|
|
412
|
+
if not validate_stocks(stocks):
|
|
413
|
+
return False
|
|
414
|
+
|
|
415
|
+
# Use the info given
|
|
416
|
+
self.account_dict[account_num] = {
|
|
417
|
+
"balance": balance if balance is not None else 0.0,
|
|
418
|
+
"withdrawal_balance": withdrawal_balance if withdrawal_balance is not None else 0.0,
|
|
419
|
+
"nickname": nickname,
|
|
420
|
+
"stocks": stocks if stocks is not None else []
|
|
421
|
+
}
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
def add_stock_to_account_dict(self, account_num: str, stock: dict, overwrite: bool = False):
|
|
427
|
+
"""
|
|
428
|
+
Add a stock to the account dict under an account.
|
|
429
|
+
You can use/import `create_stock_dict` for help.
|
|
430
|
+
|
|
431
|
+
Returns
|
|
432
|
+
-------
|
|
433
|
+
True
|
|
434
|
+
If successful
|
|
435
|
+
False
|
|
436
|
+
If account doesn't yet exist in account_dict
|
|
437
|
+
"""
|
|
438
|
+
if not validate_stocks([stock]):
|
|
439
|
+
return False
|
|
440
|
+
if account_num in self.account_dict:
|
|
441
|
+
if overwrite:
|
|
442
|
+
self.account_dict[account_num]["stocks"] = [stock]
|
|
443
|
+
self.account_dict[account_num]["balance"] = stock["value"]
|
|
444
|
+
else:
|
|
445
|
+
self.account_dict[account_num]["stocks"].append(stock)
|
|
446
|
+
self.account_dict[account_num]["balance"] += stock["value"]
|
|
447
|
+
return True
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
def add_withdrawal_bal_to_account_dict(self, account_num: str, withdrawal_balance: float, overwrite: bool = False):
|
|
451
|
+
"""
|
|
452
|
+
Add the cash available to withdrawal to the account_dict if it is 0 or overwriting
|
|
453
|
+
|
|
454
|
+
Returns
|
|
455
|
+
-------
|
|
456
|
+
True
|
|
457
|
+
If successful
|
|
458
|
+
False
|
|
459
|
+
If account doesn't yet exist in account_dict
|
|
460
|
+
"""
|
|
461
|
+
if (account_num in self.account_dict and
|
|
462
|
+
(overwrite or self.account_dict["withdrawal_balance"] == 0.0)
|
|
463
|
+
):
|
|
464
|
+
self.account_dict[account_num]["withdrawal_balance"] = withdrawal_balance
|
|
465
|
+
return True
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
def add_nickname_to_account_dict(self, account_num: str, nickname: str, overwrite: bool = False):
|
|
469
|
+
"""
|
|
470
|
+
Add the nickname to the account_dict if it is not set or overwriting
|
|
471
|
+
|
|
472
|
+
Returns
|
|
473
|
+
-------
|
|
474
|
+
True
|
|
475
|
+
If successful
|
|
476
|
+
False
|
|
477
|
+
If account doesn't yet exist in account_dict
|
|
478
|
+
"""
|
|
479
|
+
if (account_num in self.account_dict and
|
|
480
|
+
(overwrite or self.account_dict["nickname"] is None)
|
|
481
|
+
):
|
|
482
|
+
self.account_dict[account_num]["nickname"] = nickname
|
|
483
|
+
return True
|
|
484
|
+
return False
|
|
485
|
+
|
|
486
|
+
def save_storage_state(self):
|
|
487
|
+
"""
|
|
488
|
+
Saves the storage state of the browser to a file.
|
|
489
|
+
|
|
490
|
+
This method saves the storage state of the browser to a file so that it can be restored later.
|
|
491
|
+
This will do nothing if the class object was initialized with save_state=False
|
|
492
|
+
"""
|
|
493
|
+
if self.save_state:
|
|
494
|
+
storage_state = self.page.context.storage_state()
|
|
495
|
+
with open(self.profile_path, "w") as f:
|
|
496
|
+
json.dump(storage_state, f)
|
|
497
|
+
|
|
498
|
+
def close_browser(self):
|
|
499
|
+
"""
|
|
500
|
+
Closes the playwright browser.
|
|
501
|
+
Use when you are completely done with this class.
|
|
502
|
+
"""
|
|
503
|
+
# Save cookies
|
|
504
|
+
self.save_storage_state()
|
|
505
|
+
# Save screenshots if debugging
|
|
506
|
+
if self.debug:
|
|
507
|
+
self.context.tracing.stop(path=f'./fidelity_trace{self.title if self.title is not None else ""}.zip')
|
|
508
|
+
# Close context before browser as directed by documentation
|
|
509
|
+
self.context.close()
|
|
510
|
+
self.browser.close()
|
|
511
|
+
# Stop the instance of playwright
|
|
512
|
+
self.playwright.stop()
|
|
513
|
+
|
|
514
|
+
def login(self, username: str, password: str, totp_secret: str = None, save_device: bool = True) -> bool:
|
|
515
|
+
"""
|
|
516
|
+
Logs into fidelity using the supplied username and password.
|
|
517
|
+
|
|
518
|
+
If totp_secret is missing, the function will use sms code and login_2FA must be called with
|
|
519
|
+
the code to complete the login
|
|
520
|
+
|
|
521
|
+
Highly encouraged to use TOTP Secrets and to not save the device during login.
|
|
522
|
+
Not saving the device allows other functions like open_account and enable_pennystock_trading
|
|
523
|
+
to work reliably.
|
|
524
|
+
|
|
525
|
+
Parameters
|
|
526
|
+
----------
|
|
527
|
+
username (str)
|
|
528
|
+
The username of the user.
|
|
529
|
+
password (str)
|
|
530
|
+
The password of the user.
|
|
531
|
+
totp_secret (str)
|
|
532
|
+
The totp secret, if using, of the user.
|
|
533
|
+
save_device (bool)
|
|
534
|
+
Flag to allow fidelity to remember this device.
|
|
535
|
+
|
|
536
|
+
Returns
|
|
537
|
+
-------
|
|
538
|
+
True, True
|
|
539
|
+
If completely logged in
|
|
540
|
+
|
|
541
|
+
True, False
|
|
542
|
+
If 2FA is needed which signifies that the initial login attempt was successful but further action is needed to finish logging in.
|
|
543
|
+
|
|
544
|
+
False, False
|
|
545
|
+
Initial login attempt failed.
|
|
546
|
+
"""
|
|
547
|
+
try:
|
|
548
|
+
# Go to the login page
|
|
549
|
+
self.page.goto(
|
|
550
|
+
url="https://digital.fidelity.com/prgw/digital/login/full-page",
|
|
551
|
+
timeout=60000,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# Login page
|
|
555
|
+
self.page.get_by_label("Username", exact=True).click()
|
|
556
|
+
self.page.get_by_label("Username", exact=True).fill(username)
|
|
557
|
+
self.page.get_by_label("Password", exact=True).click()
|
|
558
|
+
self.page.get_by_label("Password", exact=True).fill(password)
|
|
559
|
+
self.page.get_by_role("button", name="Log in").click()
|
|
560
|
+
|
|
561
|
+
# Wait for loading spinner to go away
|
|
562
|
+
self.wait_for_loading_sign()
|
|
563
|
+
# The first spinner goes away then another one appears
|
|
564
|
+
# This has been tested many times and this is necessary
|
|
565
|
+
self.page.wait_for_timeout(1000)
|
|
566
|
+
self.wait_for_loading_sign()
|
|
567
|
+
|
|
568
|
+
if "summary" in self.page.url:
|
|
569
|
+
return (True, True)
|
|
570
|
+
|
|
571
|
+
# Check to see if TOTP secret is blank or "NA"
|
|
572
|
+
totp_secret = None if totp_secret == "NA" else totp_secret
|
|
573
|
+
|
|
574
|
+
# If we hit the 2fA page after trying to login
|
|
575
|
+
if "login" in self.page.url:
|
|
576
|
+
self.wait_for_loading_sign()
|
|
577
|
+
widget = self.page.locator("#dom-widget div").first
|
|
578
|
+
widget.wait_for(timeout=5000, state='visible')
|
|
579
|
+
# If TOTP secret is provided, we are will use the TOTP key. See if authenticator code prompt is present
|
|
580
|
+
if (totp_secret is not None and
|
|
581
|
+
self.page.get_by_role("heading", name="Enter the code from your").is_visible()
|
|
582
|
+
):
|
|
583
|
+
# Get authenticator code
|
|
584
|
+
code = pyotp.TOTP(totp_secret).now()
|
|
585
|
+
# Enter the code
|
|
586
|
+
self.page.get_by_placeholder("XXXXXX").click()
|
|
587
|
+
self.page.get_by_placeholder("XXXXXX").fill(code)
|
|
588
|
+
|
|
589
|
+
# Prevent future OTP requirements
|
|
590
|
+
if save_device:
|
|
591
|
+
# Check this box
|
|
592
|
+
self.page.locator("label").filter(has_text="Don't ask me again on this").check()
|
|
593
|
+
if (not self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked()):
|
|
594
|
+
raise Exception("Cannot check 'Don't ask me again on this device' box")
|
|
595
|
+
|
|
596
|
+
# Log in with code
|
|
597
|
+
self.page.get_by_role("button", name="Continue").click()
|
|
598
|
+
|
|
599
|
+
# Wait for loading spinner to go away
|
|
600
|
+
self.wait_for_loading_sign()
|
|
601
|
+
|
|
602
|
+
# See if we got to the summary page
|
|
603
|
+
self.page.wait_for_url(
|
|
604
|
+
"https://digital.fidelity.com/ftgw/digital/portfolio/summary",
|
|
605
|
+
timeout=20000,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
# Got to the summary page, return True
|
|
609
|
+
return (True, True)
|
|
610
|
+
|
|
611
|
+
# If the authenticator code is the only way but we don't have the secret, return error
|
|
612
|
+
if self.page.get_by_text(
|
|
613
|
+
"Enter the code from your authenticator app This security code will confirm the"
|
|
614
|
+
).is_visible():
|
|
615
|
+
raise Exception(
|
|
616
|
+
"Fidelity needs code from authenticator app but TOTP secret is not provided"
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# If the app push notification page is present
|
|
620
|
+
if self.page.get_by_role("link", name="Try another way").is_visible():
|
|
621
|
+
if save_device:
|
|
622
|
+
self.page.locator("label").filter(has_text="Don't ask me again on this").check()
|
|
623
|
+
if (not self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked()):
|
|
624
|
+
raise Exception("Cannot check 'Don't ask me again on this device' box")
|
|
625
|
+
|
|
626
|
+
# Click on alternate verification method to get OTP via text
|
|
627
|
+
self.page.get_by_role("link", name="Try another way").click()
|
|
628
|
+
|
|
629
|
+
# Press the Text me button
|
|
630
|
+
self.page.get_by_role("button", name="Text me the code").click()
|
|
631
|
+
self.page.get_by_placeholder("XXXXXX").click()
|
|
632
|
+
|
|
633
|
+
return (True, False)
|
|
634
|
+
|
|
635
|
+
# Can't get to summary and we aren't on the login page, idk what's going on
|
|
636
|
+
raise Exception("Cannot get to login page. Maybe other 2FA method present")
|
|
637
|
+
|
|
638
|
+
except PlaywrightTimeoutError:
|
|
639
|
+
print("Timeout waiting for login page to load or navigate.")
|
|
640
|
+
return (False, False)
|
|
641
|
+
except Exception as e:
|
|
642
|
+
print(f"An error occurred: {str(e)}")
|
|
643
|
+
traceback.print_exc()
|
|
644
|
+
return (False, False)
|
|
645
|
+
|
|
646
|
+
def login_2FA(self, code: str, save_device: bool = True):
|
|
647
|
+
"""
|
|
648
|
+
Completes the 2FA portion of the login using a phone text code.
|
|
649
|
+
|
|
650
|
+
Parameters
|
|
651
|
+
----------
|
|
652
|
+
code (str)
|
|
653
|
+
The one time code sent to the user's phone
|
|
654
|
+
save_device (bool)
|
|
655
|
+
Flag to allow fidelity to remember this device.
|
|
656
|
+
|
|
657
|
+
Returns
|
|
658
|
+
-------
|
|
659
|
+
True (bool)
|
|
660
|
+
If login succeeded, return true.
|
|
661
|
+
False (bool)
|
|
662
|
+
If login failed, return false.
|
|
663
|
+
"""
|
|
664
|
+
try:
|
|
665
|
+
self.page.get_by_placeholder("XXXXXX").fill(code)
|
|
666
|
+
|
|
667
|
+
if save_device:
|
|
668
|
+
# Prevent future OTP requirements
|
|
669
|
+
self.page.locator("label").filter(
|
|
670
|
+
has_text="Don't ask me again on this"
|
|
671
|
+
).check()
|
|
672
|
+
if (
|
|
673
|
+
not self.page.locator("label")
|
|
674
|
+
.filter(has_text="Don't ask me again on this")
|
|
675
|
+
.is_checked()
|
|
676
|
+
):
|
|
677
|
+
raise Exception("Cannot check 'Don't ask me again on this device' box")
|
|
678
|
+
self.page.get_by_role("button", name="Submit").click()
|
|
679
|
+
|
|
680
|
+
self.page.wait_for_url(
|
|
681
|
+
"https://digital.fidelity.com/ftgw/digital/portfolio/summary",
|
|
682
|
+
timeout=5000,
|
|
683
|
+
)
|
|
684
|
+
return True
|
|
685
|
+
|
|
686
|
+
except PlaywrightTimeoutError:
|
|
687
|
+
print("Timeout waiting for login page to load or navigate.")
|
|
688
|
+
return False
|
|
689
|
+
except Exception as e:
|
|
690
|
+
print(f"An error occurred: {str(e)}")
|
|
691
|
+
traceback.print_exc()
|
|
692
|
+
return False
|
|
693
|
+
|
|
694
|
+
def summary_holdings(self) -> dict:
|
|
695
|
+
"""
|
|
696
|
+
The getAccountInfo function `MUST` be called before this, otherwise an empty dictionary will be returned.
|
|
697
|
+
The keys of the outer dictionary are the tickers of the stocks owned.
|
|
698
|
+
Ex: `unique_stocks['NVDA'] = {'quantity': 2.0, 'last_price': 120.23, 'value': 240.46}`
|
|
699
|
+
|
|
700
|
+
Returns
|
|
701
|
+
-------
|
|
702
|
+
unique_stocks (dict)
|
|
703
|
+
A dictionary containing dictionaries for each stock owned across all accounts.
|
|
704
|
+
```
|
|
705
|
+
{
|
|
706
|
+
'quantity': float: The number of stocks held of 'ticker'
|
|
707
|
+
'last_price': float: The last price of the stock
|
|
708
|
+
'value': float: The total value of the stocks held
|
|
709
|
+
}
|
|
710
|
+
```
|
|
711
|
+
"""
|
|
712
|
+
|
|
713
|
+
unique_stocks = {}
|
|
714
|
+
|
|
715
|
+
for account_number in self.account_dict:
|
|
716
|
+
for stock_dict in self.account_dict[account_number]["stocks"]:
|
|
717
|
+
# Create a list of unique holdings
|
|
718
|
+
if stock_dict["ticker"] not in unique_stocks:
|
|
719
|
+
unique_stocks[stock_dict["ticker"]] = {
|
|
720
|
+
"quantity": float(stock_dict["quantity"]),
|
|
721
|
+
"last_price": float(stock_dict["last_price"]),
|
|
722
|
+
"value": float(stock_dict["value"]),
|
|
723
|
+
}
|
|
724
|
+
else:
|
|
725
|
+
unique_stocks[stock_dict["ticker"]]["quantity"] += float(
|
|
726
|
+
stock_dict["quantity"]
|
|
727
|
+
)
|
|
728
|
+
unique_stocks[stock_dict["ticker"]]["value"] += float(
|
|
729
|
+
stock_dict["value"]
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
return unique_stocks
|
|
733
|
+
|
|
734
|
+
def transaction(self, stock: str, quantity: float, action: str, account: str, dry: bool = True) -> bool:
|
|
735
|
+
"""
|
|
736
|
+
Process an order (transaction) using the dedicated trading page.
|
|
737
|
+
|
|
738
|
+
`NOTE`: If you use this function repeatedly but change the stock between ANY call,
|
|
739
|
+
RELOAD the page before calling this
|
|
740
|
+
|
|
741
|
+
For buying:
|
|
742
|
+
If the price of the security is below $1, it will choose limit order and go off of the last price + a little
|
|
743
|
+
For selling:
|
|
744
|
+
Places a market order for the security
|
|
745
|
+
|
|
746
|
+
Parameters
|
|
747
|
+
----------
|
|
748
|
+
stock (str)
|
|
749
|
+
The ticker that represents the security to be traded
|
|
750
|
+
quantity (float)
|
|
751
|
+
The amount to buy or sell of the security
|
|
752
|
+
action (str)
|
|
753
|
+
This must be 'buy' or 'sell'. It can be in any case state (i.e. 'bUY' is still valid)
|
|
754
|
+
account (str)
|
|
755
|
+
The account number to trade under.
|
|
756
|
+
dry (bool)
|
|
757
|
+
True for dry (test) run, False for real run.
|
|
758
|
+
|
|
759
|
+
Returns
|
|
760
|
+
-------
|
|
761
|
+
(Success (bool), Error_message (str))
|
|
762
|
+
If the order was successfully placed or tested (for dry runs) then True is
|
|
763
|
+
returned and Error_message will be None. Otherwise, False will be returned and Error_message will not be None
|
|
764
|
+
"""
|
|
765
|
+
try:
|
|
766
|
+
# Go to the trade page
|
|
767
|
+
self.page.wait_for_load_state(state="load")
|
|
768
|
+
if (self.page.url != "https://digital.fidelity.com/ftgw/digital/trade-equity/index/orderEntry"):
|
|
769
|
+
self.page.goto("https://digital.fidelity.com/ftgw/digital/trade-equity/index/orderEntry")
|
|
770
|
+
|
|
771
|
+
# Click on the drop down
|
|
772
|
+
self.page.query_selector("#dest-acct-dropdown").click()
|
|
773
|
+
|
|
774
|
+
if (not self.page.get_by_role("option").filter(has_text=account.upper()).is_visible()):
|
|
775
|
+
# Reload the page and hit the drop down again
|
|
776
|
+
# This is to prevent a rare case where the drop down is empty
|
|
777
|
+
print("Reloading...")
|
|
778
|
+
self.page.reload()
|
|
779
|
+
# Click on the drop down
|
|
780
|
+
self.page.query_selector("#dest-acct-dropdown").click()
|
|
781
|
+
# Find the account to trade under
|
|
782
|
+
self.page.get_by_role("option").filter(has_text=account.upper()).click()
|
|
783
|
+
|
|
784
|
+
# Enter the symbol
|
|
785
|
+
self.page.get_by_label("Symbol").click()
|
|
786
|
+
# Fill in the ticker
|
|
787
|
+
self.page.get_by_label("Symbol").fill(stock)
|
|
788
|
+
# Force the search to use exactly what was entered
|
|
789
|
+
self.page.get_by_label("Symbol").press("Enter")
|
|
790
|
+
|
|
791
|
+
# Wait for quote panel to show up
|
|
792
|
+
self.page.locator("#quote-panel").wait_for(timeout=5000)
|
|
793
|
+
last_price = self.page.query_selector("#eq-ticket__last-price > span.last-price").text_content()
|
|
794
|
+
last_price = last_price.replace("$", "")
|
|
795
|
+
|
|
796
|
+
# Ensure we are in the expanded ticket
|
|
797
|
+
if self.page.get_by_role("button", name="View expanded ticket").is_visible():
|
|
798
|
+
self.page.get_by_role("button", name="View expanded ticket").click()
|
|
799
|
+
# Wait for it to take effect
|
|
800
|
+
self.page.get_by_role("button", name="Calculate shares").wait_for(timeout=5000)
|
|
801
|
+
|
|
802
|
+
# When enabling extended hour trading
|
|
803
|
+
extended = False
|
|
804
|
+
precision = 3
|
|
805
|
+
# Enable extended hours trading if available
|
|
806
|
+
if self.page.get_by_text("Extended hours trading").is_visible():
|
|
807
|
+
if self.page.get_by_text("Extended hours trading: OffUntil 8:00 PM ET").is_visible():
|
|
808
|
+
self.page.get_by_text("Extended hours trading: OffUntil 8:00 PM ET").check()
|
|
809
|
+
extended = True
|
|
810
|
+
precision = 2
|
|
811
|
+
|
|
812
|
+
# Press the buy or sell button. Title capitalizes the first letter so 'buy' -> 'Buy'
|
|
813
|
+
self.page.query_selector(".eq-ticket-action-label").click()
|
|
814
|
+
self.page.get_by_role("option", name=action.lower().title(), exact=True).wait_for()
|
|
815
|
+
self.page.get_by_role("option", name=action.lower().title(), exact=True).click()
|
|
816
|
+
|
|
817
|
+
# Press the shares text box
|
|
818
|
+
self.page.locator("#eqt-mts-stock-quatity div").filter(has_text="Quantity").click()
|
|
819
|
+
self.page.get_by_text("Quantity", exact=True).fill(str(quantity))
|
|
820
|
+
|
|
821
|
+
# If it should be limit
|
|
822
|
+
if float(last_price) < 1 or extended:
|
|
823
|
+
# Buy above
|
|
824
|
+
if action.lower() == "buy":
|
|
825
|
+
difference_price = 0.01 if float(last_price) > 0.1 else 0.0001
|
|
826
|
+
wanted_price = round(float(last_price) + difference_price, precision)
|
|
827
|
+
# Sell below
|
|
828
|
+
else:
|
|
829
|
+
difference_price = 0.01 if float(last_price) > 0.1 else 0.0001
|
|
830
|
+
wanted_price = round(float(last_price) - difference_price, precision)
|
|
831
|
+
|
|
832
|
+
# Click on the limit default option when in extended hours
|
|
833
|
+
self.page.query_selector("#dest-dropdownlist-button-ordertype > span:nth-child(1)").click()
|
|
834
|
+
self.page.get_by_role("option", name="Limit", exact=True).click()
|
|
835
|
+
# Enter the limit price
|
|
836
|
+
self.page.get_by_text("Limit price", exact=True).click()
|
|
837
|
+
self.page.get_by_label("Limit price").fill(str(wanted_price))
|
|
838
|
+
# Otherwise its market
|
|
839
|
+
else:
|
|
840
|
+
# Click on the market
|
|
841
|
+
self.page.locator("#order-type-container-id").click()
|
|
842
|
+
self.page.get_by_role("option", name="Market", exact=True).click()
|
|
843
|
+
|
|
844
|
+
# Continue with the order
|
|
845
|
+
self.page.get_by_role("button", name="Preview order").click()
|
|
846
|
+
self.wait_for_loading_sign()
|
|
847
|
+
|
|
848
|
+
# If error occurred
|
|
849
|
+
try:
|
|
850
|
+
self.page.get_by_role("button", name="Place order", exact=False).wait_for(timeout=5000, state="visible")
|
|
851
|
+
except PlaywrightTimeoutError:
|
|
852
|
+
# Error must be present (or really slow page for some reason)
|
|
853
|
+
# Try to report on error
|
|
854
|
+
error_message = ""
|
|
855
|
+
filtered_error = ""
|
|
856
|
+
error_box_closed = False
|
|
857
|
+
try:
|
|
858
|
+
error_message = (self.page.get_by_label("Error").locator("div").filter(has_text="critical").nth(2).text_content(timeout=2000))
|
|
859
|
+
self.page.get_by_role("button", name="Close dialog").click()
|
|
860
|
+
error_box_closed = True
|
|
861
|
+
except Exception:
|
|
862
|
+
pass
|
|
863
|
+
if error_message == "":
|
|
864
|
+
try:
|
|
865
|
+
error_message = self.page.wait_for_selector('.pvd-inline-alert__content font[color="red"]', timeout=2000).text_content()
|
|
866
|
+
self.page.get_by_role("button", name="Close dialog").click()
|
|
867
|
+
error_box_closed = True
|
|
868
|
+
except Exception:
|
|
869
|
+
pass
|
|
870
|
+
# Return with error and trim it down (it contains many spaces for some reason)
|
|
871
|
+
if error_message != "":
|
|
872
|
+
for i, character in enumerate(error_message):
|
|
873
|
+
if (
|
|
874
|
+
(character == " " and error_message[i - 1] == " ")
|
|
875
|
+
or character == "\n"
|
|
876
|
+
or character == "\t"
|
|
877
|
+
):
|
|
878
|
+
continue
|
|
879
|
+
filtered_error += character
|
|
880
|
+
|
|
881
|
+
error_message = filtered_error.replace("critical", "").strip().replace("\n", "")
|
|
882
|
+
else:
|
|
883
|
+
error_message = "Could not retrieve error message from popup"
|
|
884
|
+
|
|
885
|
+
# If the error box is still open, reload the page
|
|
886
|
+
if not error_box_closed:
|
|
887
|
+
self.page.reload()
|
|
888
|
+
return (False, error_message)
|
|
889
|
+
|
|
890
|
+
# If no error occurred, continue with checking the order preview
|
|
891
|
+
if (not self.page.locator("preview").filter(has_text=account.upper()).is_visible()
|
|
892
|
+
or not self.page.get_by_text(f"Symbol{stock.upper()}", exact=True).is_visible()
|
|
893
|
+
or not self.page.get_by_text(f"Action{action.lower().title()}").is_visible()
|
|
894
|
+
or not self.page.get_by_text(f"Quantity{quantity}").is_visible()
|
|
895
|
+
):
|
|
896
|
+
return (False, "Order preview is not what is expected")
|
|
897
|
+
|
|
898
|
+
# If its a real run
|
|
899
|
+
if not dry:
|
|
900
|
+
self.page.get_by_role("button", name="Place order", exact=False).first.click()
|
|
901
|
+
try:
|
|
902
|
+
self.wait_for_loading_sign()
|
|
903
|
+
# See that the order goes through
|
|
904
|
+
self.page.get_by_text("Order received", exact=True).wait_for(timeout=10000, state="visible")
|
|
905
|
+
# If no error, return with success
|
|
906
|
+
return (True, None)
|
|
907
|
+
except PlaywrightTimeoutError as toe:
|
|
908
|
+
# Order didn't go through for some reason, go to the next and say error
|
|
909
|
+
return (False, f"Timed out waiting for 'Order received': {toe}")
|
|
910
|
+
# If its a dry run, report back success
|
|
911
|
+
return (True, None)
|
|
912
|
+
except PlaywrightTimeoutError as toe:
|
|
913
|
+
return (False, f"Driver timed out. Order not complete: {toe}")
|
|
914
|
+
except Exception as e:
|
|
915
|
+
return (False, f"Some error occurred: {e}")
|
|
916
|
+
|
|
917
|
+
def open_account(self, type: typing.Optional[Literal["roth", "brokerage"]]) -> bool:
|
|
918
|
+
"""
|
|
919
|
+
Opens either a brokerage or roth account. If a roth account is opened, the new account number is stored in
|
|
920
|
+
`self.new_account_number`
|
|
921
|
+
|
|
922
|
+
`NOTE` Use login(save_device=False) when logging in.
|
|
923
|
+
If you do not authenticate with 2FA when creating this session and the device is remembered from a pervious
|
|
924
|
+
login, fidelity can attempt to authenticate again which causes this function to fail.
|
|
925
|
+
|
|
926
|
+
Parameters
|
|
927
|
+
----------
|
|
928
|
+
type (str)
|
|
929
|
+
The type of account to open.
|
|
930
|
+
|
|
931
|
+
Returns
|
|
932
|
+
-------
|
|
933
|
+
success (bool)
|
|
934
|
+
If the account was successfully opened
|
|
935
|
+
"""
|
|
936
|
+
try:
|
|
937
|
+
if type == "roth":
|
|
938
|
+
# Go to open roth page
|
|
939
|
+
self.page.goto(url="https://digital.fidelity.com/ftgw/digital/aox/RothIRAccountOpening/PersonalInformation")
|
|
940
|
+
self.wait_for_loading_sign()
|
|
941
|
+
|
|
942
|
+
# Open an account
|
|
943
|
+
self.page.get_by_role("button", name="Open account").click()
|
|
944
|
+
self.wait_for_loading_sign(timeout=60000)
|
|
945
|
+
congrats_message = self.page.get_by_role("heading", name="Congratulations, your account")
|
|
946
|
+
congrats_message.wait_for(state="visible")
|
|
947
|
+
|
|
948
|
+
# Get the account number
|
|
949
|
+
self.new_account_number = self.page.get_by_role("heading", name="Your account number is").text_content()
|
|
950
|
+
self.new_account_number = self.new_account_number.replace("Your account number is ", "")
|
|
951
|
+
return True
|
|
952
|
+
if type == "brokerage":
|
|
953
|
+
# Get list of accounts first
|
|
954
|
+
old_dict = self.get_list_of_accounts(set_flag=False)
|
|
955
|
+
|
|
956
|
+
# Go to individual brokerage page
|
|
957
|
+
self.page.goto(url="https://digital.fidelity.com/ftgw/digital/aox/BrokerageAccountOpening/JointSelectionPage")
|
|
958
|
+
self.wait_for_loading_sign()
|
|
959
|
+
|
|
960
|
+
# First section (This won't be present if an application was already started)
|
|
961
|
+
if self.page.get_by_role("heading", name="Account ownership").is_visible():
|
|
962
|
+
self.page.get_by_role("button", name="Next").click()
|
|
963
|
+
self.wait_for_loading_sign()
|
|
964
|
+
|
|
965
|
+
# If application is already started, then there will only be 1 "Next" button
|
|
966
|
+
self.page.get_by_role("button", name="Next").click()
|
|
967
|
+
self.wait_for_loading_sign()
|
|
968
|
+
|
|
969
|
+
# Open account
|
|
970
|
+
self.page.get_by_role("button", name="Open account").click()
|
|
971
|
+
self.wait_for_loading_sign(timeout=60000) # Can take a while to open sometimes
|
|
972
|
+
|
|
973
|
+
# Wait for page to load completely
|
|
974
|
+
self.page.wait_for_load_state(state='load')
|
|
975
|
+
self.wait_for_loading_sign()
|
|
976
|
+
|
|
977
|
+
## Getting the account number ##
|
|
978
|
+
# Get new list of accounts
|
|
979
|
+
new_dict = self.get_list_of_accounts(set_flag=False)
|
|
980
|
+
# Reset new account number in case this was set before
|
|
981
|
+
self.new_account_number = None
|
|
982
|
+
# Compare old and new list
|
|
983
|
+
for new_dict_acc in new_dict:
|
|
984
|
+
# If new account is found, collect and return
|
|
985
|
+
if new_dict_acc not in old_dict:
|
|
986
|
+
self.new_account_number = new_dict_acc
|
|
987
|
+
print(self.new_account_number)
|
|
988
|
+
return True
|
|
989
|
+
|
|
990
|
+
# No new account number was found, return false
|
|
991
|
+
return False
|
|
992
|
+
|
|
993
|
+
return False
|
|
994
|
+
except Exception as e:
|
|
995
|
+
print(e)
|
|
996
|
+
self.page.pause()
|
|
997
|
+
return False
|
|
998
|
+
|
|
999
|
+
def transfer_acc_to_acc(self, source_account: str, destination_account: str, transfer_amount: float) -> bool:
|
|
1000
|
+
"""
|
|
1001
|
+
Transfers requested amount from source account to destination account.
|
|
1002
|
+
|
|
1003
|
+
Parameters
|
|
1004
|
+
----------
|
|
1005
|
+
source_account (str)
|
|
1006
|
+
The account number of the source account.
|
|
1007
|
+
destination_account (str)
|
|
1008
|
+
The account number of the destination account.
|
|
1009
|
+
transfer_amount (float)
|
|
1010
|
+
The amount to transfer.
|
|
1011
|
+
|
|
1012
|
+
Returns
|
|
1013
|
+
-------
|
|
1014
|
+
bool
|
|
1015
|
+
True if the transfer was successful, False otherwise.
|
|
1016
|
+
"""
|
|
1017
|
+
try:
|
|
1018
|
+
# Navigate to the transfer page
|
|
1019
|
+
self.page.wait_for_load_state(state="load")
|
|
1020
|
+
self.page.goto(url="https://digital.fidelity.com/ftgw/digital/transfer/?quicktransfer=cash-shares")
|
|
1021
|
+
self.wait_for_loading_sign()
|
|
1022
|
+
|
|
1023
|
+
# Select the source account from the 'From' dropdown
|
|
1024
|
+
from_select = self.page.get_by_label("From")
|
|
1025
|
+
options = from_select.locator("option").all()
|
|
1026
|
+
source_value = None
|
|
1027
|
+
for option in options:
|
|
1028
|
+
if source_account in option.inner_text():
|
|
1029
|
+
source_value = option.get_attribute("value")
|
|
1030
|
+
break
|
|
1031
|
+
|
|
1032
|
+
if source_value is None:
|
|
1033
|
+
print(f"Source account {source_account} not found in dropdown")
|
|
1034
|
+
return False
|
|
1035
|
+
|
|
1036
|
+
from_select.select_option(source_value)
|
|
1037
|
+
self.wait_for_loading_sign()
|
|
1038
|
+
|
|
1039
|
+
# Select the new account from the 'To' dropdown
|
|
1040
|
+
to_select = self.page.get_by_label("To", exact=True)
|
|
1041
|
+
options = to_select.locator("option").all()
|
|
1042
|
+
destination_value = None
|
|
1043
|
+
for option in options:
|
|
1044
|
+
if destination_account in option.inner_text():
|
|
1045
|
+
destination_value = option.get_attribute("value")
|
|
1046
|
+
break
|
|
1047
|
+
|
|
1048
|
+
if destination_value is None:
|
|
1049
|
+
print(f"Account {destination_account} not found in 'To' dropdown")
|
|
1050
|
+
return False
|
|
1051
|
+
|
|
1052
|
+
to_select.select_option(destination_value)
|
|
1053
|
+
self.wait_for_loading_sign()
|
|
1054
|
+
|
|
1055
|
+
# Get the available balance
|
|
1056
|
+
available_balance = self.page.locator("tr.pvd-table__row:nth-child(2) > td:nth-child(2)").inner_text()
|
|
1057
|
+
available_balance = float(available_balance.replace("$", "").replace(",", ""))
|
|
1058
|
+
|
|
1059
|
+
# Check if there's enough balance
|
|
1060
|
+
if transfer_amount > available_balance:
|
|
1061
|
+
print(f"Insufficient funds. Available: ${available_balance}, Attempted transfer: ${transfer_amount}")
|
|
1062
|
+
return False
|
|
1063
|
+
|
|
1064
|
+
# Enter the transfer amount
|
|
1065
|
+
self.page.locator("#transfer-amount").fill(str(transfer_amount))
|
|
1066
|
+
|
|
1067
|
+
# Submit the transfer
|
|
1068
|
+
self.page.get_by_role("button", name="Continue").click()
|
|
1069
|
+
self.wait_for_loading_sign()
|
|
1070
|
+
self.page.get_by_role("button", name="Submit").click()
|
|
1071
|
+
self.wait_for_loading_sign()
|
|
1072
|
+
|
|
1073
|
+
try:
|
|
1074
|
+
# Check if the transfer was successful
|
|
1075
|
+
self.page.get_by_text("Request submitted").wait_for(state='visible')
|
|
1076
|
+
except PlaywrightTimeoutError:
|
|
1077
|
+
print("Transfer submission failed")
|
|
1078
|
+
return False
|
|
1079
|
+
|
|
1080
|
+
return True
|
|
1081
|
+
|
|
1082
|
+
except Exception as e:
|
|
1083
|
+
print(f"An error occurred during the transfer: {str(e)}")
|
|
1084
|
+
return False
|
|
1085
|
+
|
|
1086
|
+
def enable_pennystock_trading(self, account: str) -> bool:
|
|
1087
|
+
"""
|
|
1088
|
+
Enables penny stock trading for the account given.
|
|
1089
|
+
The account is just the account number, no nickname and no parenthesis
|
|
1090
|
+
|
|
1091
|
+
`NOTE` Use login(save_device=False) when logging in.
|
|
1092
|
+
If you do not authenticate with 2FA when creating this session and the device is remembered from a pervious
|
|
1093
|
+
login, fidelity can attempt to authenticate again which causes this function to fail.
|
|
1094
|
+
|
|
1095
|
+
Problems
|
|
1096
|
+
--------
|
|
1097
|
+
When the checkbox version comes around, sometimes it takes forever to load.
|
|
1098
|
+
When reloading the page or navigating away, it makes you sign in again
|
|
1099
|
+
|
|
1100
|
+
Parameters
|
|
1101
|
+
----------
|
|
1102
|
+
account (str)
|
|
1103
|
+
The account number to enable this feature for
|
|
1104
|
+
|
|
1105
|
+
Returns
|
|
1106
|
+
-------
|
|
1107
|
+
bool
|
|
1108
|
+
If account was successfully enabled or not
|
|
1109
|
+
"""
|
|
1110
|
+
try:
|
|
1111
|
+
self.page.wait_for_load_state(state="load")
|
|
1112
|
+
self.page.goto(url="https://digital.fidelity.com/ftgw/digital/portfolio/features")
|
|
1113
|
+
self.page.get_by_label("Manage Penny Stock Trading").click()
|
|
1114
|
+
|
|
1115
|
+
self.page.wait_for_load_state(state="load", timeout=30000)
|
|
1116
|
+
self.wait_for_loading_sign()
|
|
1117
|
+
|
|
1118
|
+
# Wait for and click the Start button
|
|
1119
|
+
self.page.get_by_role("button", name="Start").click(timeout=15000)
|
|
1120
|
+
self.wait_for_loading_sign()
|
|
1121
|
+
|
|
1122
|
+
# See if we can enable any accounts
|
|
1123
|
+
try:
|
|
1124
|
+
self.page.get_by_text("This feature is already enabled").wait_for(state="visible", timeout=1000)
|
|
1125
|
+
print("All accounts have penny stock trading enabled already")
|
|
1126
|
+
return True
|
|
1127
|
+
except PlaywrightTimeoutError:
|
|
1128
|
+
pass
|
|
1129
|
+
# Ensure the page is loaded
|
|
1130
|
+
select_account_title = self.page.get_by_role("heading", name="Select an account")
|
|
1131
|
+
select_account_title.wait_for(timeout=30000, state="visible")
|
|
1132
|
+
|
|
1133
|
+
# There are 2 versions of this. A checkbox and a drop down
|
|
1134
|
+
|
|
1135
|
+
# Checkbox version
|
|
1136
|
+
# This one seems to have trouble with infinite loading sign
|
|
1137
|
+
if self.page.locator("label").filter(has_text=account).is_visible():
|
|
1138
|
+
# This seems to never work for checkbox version so reload and try for dropdown version
|
|
1139
|
+
self.page.locator("label").filter(has_text=account).click()
|
|
1140
|
+
|
|
1141
|
+
# Dropdown version
|
|
1142
|
+
if self.page.get_by_label("Your eligible accounts").is_visible():
|
|
1143
|
+
self.page.get_by_label("Your eligible accounts").select_option(account)
|
|
1144
|
+
|
|
1145
|
+
# Continue with enabling
|
|
1146
|
+
self.page.get_by_role("button", name="Continue").click()
|
|
1147
|
+
try:
|
|
1148
|
+
self.wait_for_loading_sign(timeout=60000)
|
|
1149
|
+
except PlaywrightTimeoutError:
|
|
1150
|
+
# Reload
|
|
1151
|
+
# TODO Still some problems here. It takes you to the login page upon navigating when the loading
|
|
1152
|
+
# sign is taking forever
|
|
1153
|
+
return self.enable_pennystock_trading(account=account)
|
|
1154
|
+
try:
|
|
1155
|
+
# Wait for extra loading
|
|
1156
|
+
self.page.wait_for_load_state(state="load")
|
|
1157
|
+
self.wait_for_loading_sign()
|
|
1158
|
+
# First link is more common, second link sometimes happens when going through checkbox page
|
|
1159
|
+
if ("https://digital.fidelity.com/ftgw/digital/easy/hrt/pst/termsandconditions" not in self.page.url and
|
|
1160
|
+
"https://digital.fidelity.com/ftgw/digital/brokerage-host/psta/TermsAndCondtions" not in self.page.url
|
|
1161
|
+
):
|
|
1162
|
+
return False
|
|
1163
|
+
# self.page.wait_for_url(url="https://digital.fidelity.com/ftgw/digital/easy/hrt/pst/termsandconditions")
|
|
1164
|
+
# TODO This is the page that it navigates to after the checkbox version
|
|
1165
|
+
# https://digital.fidelity.com/ftgw/digital/brokerage-host/psta/TermsAndCondtions
|
|
1166
|
+
# Also the page doesnt say success if it goes here. it says You're all set!. See pic in downloads
|
|
1167
|
+
except PlaywrightTimeoutError as e:
|
|
1168
|
+
if not "termsandconditions" in self.page.url.lower():
|
|
1169
|
+
raise Exception(e)
|
|
1170
|
+
# Accept the risks
|
|
1171
|
+
self.page.query_selector(".pvd-checkbox__label").click()
|
|
1172
|
+
self.page.get_by_role("button", name="Submit").click()
|
|
1173
|
+
self.wait_for_loading_sign()
|
|
1174
|
+
self.page.wait_for_load_state(state="load")
|
|
1175
|
+
self.wait_for_loading_sign()
|
|
1176
|
+
# Verify success
|
|
1177
|
+
try:
|
|
1178
|
+
success_ribbon = self.page.get_by_text("Your account is now enabled.")
|
|
1179
|
+
success_ribbon.wait_for(state="visible", timeout=15000)
|
|
1180
|
+
except PlaywrightTimeoutError:
|
|
1181
|
+
print(f"Couldn't verify penny stock enabled. Error: {e}")
|
|
1182
|
+
return False
|
|
1183
|
+
# Return with success
|
|
1184
|
+
return True
|
|
1185
|
+
|
|
1186
|
+
except Exception as e:
|
|
1187
|
+
print(f"Error: {e}")
|
|
1188
|
+
return False
|
|
1189
|
+
|
|
1190
|
+
def download_prev_statement(self, date: str):
|
|
1191
|
+
"""
|
|
1192
|
+
Downloads the multi-account statement for the given month.
|
|
1193
|
+
TODO: feature that goes to certain year or period if given date is more than 6 months before current date
|
|
1194
|
+
TODO: ranges of date matching. Fidelity sometimes combines statements of months. Ex: Jan - April
|
|
1195
|
+
|
|
1196
|
+
Parameters
|
|
1197
|
+
----------
|
|
1198
|
+
date (str)
|
|
1199
|
+
The month and year for the statement to download. Format of `MM/YYYY`
|
|
1200
|
+
|
|
1201
|
+
Returns
|
|
1202
|
+
-------
|
|
1203
|
+
statement (str)
|
|
1204
|
+
The full path of the file downloaded
|
|
1205
|
+
"""
|
|
1206
|
+
|
|
1207
|
+
# Trim date down
|
|
1208
|
+
month = date[:2]
|
|
1209
|
+
year = date[-4:]
|
|
1210
|
+
|
|
1211
|
+
# Convert to month
|
|
1212
|
+
month = fid_months(int(month)).name
|
|
1213
|
+
|
|
1214
|
+
# Build statement name string
|
|
1215
|
+
beginning = str(month) + " " + year
|
|
1216
|
+
# Convert to 3 letter month followed by year
|
|
1217
|
+
self.page.wait_for_load_state(state="load")
|
|
1218
|
+
self.page.goto(url="https://digital.fidelity.com/ftgw/digital/portfolio/documents/dochub")
|
|
1219
|
+
self.page.get_by_role("row", name=f"{beginning} — Statement (pdf)").get_by_label("download statement").click()
|
|
1220
|
+
with self.page.expect_download() as download_info:
|
|
1221
|
+
with self.page.expect_popup() as page1_info:
|
|
1222
|
+
self.page.get_by_role("menuitem", name="Download as PDF").click()
|
|
1223
|
+
page1 = page1_info.value
|
|
1224
|
+
download = download_info.value
|
|
1225
|
+
cur = os.getcwd()
|
|
1226
|
+
statement = os.path.join(cur, download.suggested_filename)
|
|
1227
|
+
# Create a copy to work on with the proper file name known
|
|
1228
|
+
download.save_as(statement)
|
|
1229
|
+
page1.close()
|
|
1230
|
+
return statement
|
|
1231
|
+
|
|
1232
|
+
def wait_for_loading_sign(self, timeout: int = 30000):
|
|
1233
|
+
"""
|
|
1234
|
+
Waits for known loading signs present in Fidelity by looping through a list of discovered types.
|
|
1235
|
+
Each iteration uses the timeout given.
|
|
1236
|
+
|
|
1237
|
+
Parameters
|
|
1238
|
+
----------
|
|
1239
|
+
timeout (int)
|
|
1240
|
+
The number of milliseconds to wait before throwing a PlaywrightTimeoutError exception
|
|
1241
|
+
"""
|
|
1242
|
+
|
|
1243
|
+
# Wait for all kinds of loading signs
|
|
1244
|
+
signs = [self.page.locator("div:nth-child(2) > .loading-spinner-mask-after").first,
|
|
1245
|
+
self.page.locator(".pvd-spinner__mask-inner").first,
|
|
1246
|
+
self.page.locator("pvd-loading-spinner").first,
|
|
1247
|
+
]
|
|
1248
|
+
for sign in signs:
|
|
1249
|
+
sign.wait_for(timeout=timeout, state="hidden")
|
|
1250
|
+
|
|
1251
|
+
def nickname_account(self, account_number: str, nickname: str):
|
|
1252
|
+
"""
|
|
1253
|
+
Nicknames an account with the provided string.
|
|
1254
|
+
|
|
1255
|
+
Parameters
|
|
1256
|
+
----------
|
|
1257
|
+
account_number (str)
|
|
1258
|
+
The account number for the account to be nicknamed. Ex: `Z12345678`
|
|
1259
|
+
nickname (str)
|
|
1260
|
+
The nickname to use
|
|
1261
|
+
"""
|
|
1262
|
+
try:
|
|
1263
|
+
# Get to summary page
|
|
1264
|
+
self.page.wait_for_load_state(state='load')
|
|
1265
|
+
self.page.goto(url="https://digital.fidelity.com/ftgw/digital/portfolio/summary")
|
|
1266
|
+
self.wait_for_loading_sign()
|
|
1267
|
+
|
|
1268
|
+
# Wait for customize button
|
|
1269
|
+
self.page.get_by_label("Customize Accounts", exact=True).wait_for(state='visible')
|
|
1270
|
+
new_view = False
|
|
1271
|
+
|
|
1272
|
+
# Check for newer customize button
|
|
1273
|
+
if self.page.get_by_test_id("ap143528-account-customize-open-button").get_by_label("Customize Accounts").is_visible():
|
|
1274
|
+
# New view detected
|
|
1275
|
+
new_view = True
|
|
1276
|
+
# Click customize button
|
|
1277
|
+
self.page.get_by_label("Customize Accounts", exact=True).click()
|
|
1278
|
+
self.page.get_by_text("Display preferences").wait_for(state='visible')
|
|
1279
|
+
|
|
1280
|
+
entries = self.page.locator(".custom-modal__accounts-item").first.wait_for(state='visible')
|
|
1281
|
+
entries = self.page.locator(".custom-modal__accounts-item").all()
|
|
1282
|
+
selected_entry = None
|
|
1283
|
+
for item in entries:
|
|
1284
|
+
if account_number in item.inner_text():
|
|
1285
|
+
selected_entry = item
|
|
1286
|
+
break
|
|
1287
|
+
|
|
1288
|
+
# See if we found something
|
|
1289
|
+
if selected_entry is None:
|
|
1290
|
+
return False
|
|
1291
|
+
|
|
1292
|
+
# Click it
|
|
1293
|
+
selected_entry.click()
|
|
1294
|
+
|
|
1295
|
+
# Click the rename button
|
|
1296
|
+
self.page.get_by_role("button", name="Rename").click()
|
|
1297
|
+
|
|
1298
|
+
# Enter the new name
|
|
1299
|
+
if new_view:
|
|
1300
|
+
self.page.get_by_test_id("ap143528-account-customize-account-input").get_by_role("textbox").fill(nickname)
|
|
1301
|
+
else:
|
|
1302
|
+
self.page.get_by_label("Accounts", exact=True).get_by_role("textbox").fill(nickname)
|
|
1303
|
+
|
|
1304
|
+
self.page.get_by_role("button", name="save").click()
|
|
1305
|
+
# 2 loading signs follow this
|
|
1306
|
+
self.wait_for_loading_sign()
|
|
1307
|
+
self.wait_for_loading_sign()
|
|
1308
|
+
|
|
1309
|
+
return True
|
|
1310
|
+
|
|
1311
|
+
except Exception as e:
|
|
1312
|
+
print(e)
|
|
1313
|
+
return False
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def create_stock_dict(ticker: str, quantity: float, last_price: float, value: float, stock_list: list = None):
|
|
1318
|
+
"""
|
|
1319
|
+
Creates a dictionary for a stock.
|
|
1320
|
+
Appends it to a list if provided
|
|
1321
|
+
|
|
1322
|
+
Returns
|
|
1323
|
+
-------
|
|
1324
|
+
stock_dict (dict)
|
|
1325
|
+
The dictionary for the stock with given info
|
|
1326
|
+
"""
|
|
1327
|
+
# Build the dict for the stock
|
|
1328
|
+
stock_dict = {
|
|
1329
|
+
"ticker": ticker,
|
|
1330
|
+
"quantity": quantity,
|
|
1331
|
+
"last_price": last_price,
|
|
1332
|
+
"value": value,
|
|
1333
|
+
}
|
|
1334
|
+
if stock_list is not None:
|
|
1335
|
+
stock_list.append(stock_dict)
|
|
1336
|
+
return stock_dict
|
|
1337
|
+
|
|
1338
|
+
def validate_stocks(stocks: list):
|
|
1339
|
+
"""
|
|
1340
|
+
Checks a list of stocks (which are dictionaries) for valid fields
|
|
1341
|
+
|
|
1342
|
+
Returns
|
|
1343
|
+
-------
|
|
1344
|
+
True
|
|
1345
|
+
If stocks are none or valid
|
|
1346
|
+
False
|
|
1347
|
+
If fields are left empty or type are incorrect
|
|
1348
|
+
"""
|
|
1349
|
+
if stocks is not None:
|
|
1350
|
+
for stock in stocks:
|
|
1351
|
+
try:
|
|
1352
|
+
if (stock["ticker"] is None or
|
|
1353
|
+
stock["quantity"] is None or
|
|
1354
|
+
stock["last_price"] is None or
|
|
1355
|
+
stock["value"] is None
|
|
1356
|
+
):
|
|
1357
|
+
raise Exception("Missing fields")
|
|
1358
|
+
if (type(stock["ticker"]) is not str or
|
|
1359
|
+
type(stock["quantity"]) is not float or
|
|
1360
|
+
type(stock["last_price"]) is not float or
|
|
1361
|
+
type(stock["value"]) is not float
|
|
1362
|
+
):
|
|
1363
|
+
raise Exception("Incorrect types for entries")
|
|
1364
|
+
except Exception as e:
|
|
1365
|
+
print(f"Error in stocks list. {e}")
|
|
1366
|
+
print("Create list of dictionaries with the following fields populated to initialize with given list")
|
|
1367
|
+
print("ticker: str")
|
|
1368
|
+
print("quantity: float")
|
|
1369
|
+
print("last_price: float")
|
|
1370
|
+
print("value: float")
|
|
1371
|
+
return False
|
|
1372
|
+
return True
|
|
1373
|
+
|