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
+