From 8e33f3ab8eb5ea78e086b2ef9265293600f925fa Mon Sep 17 00:00:00 2001 From: Kenny <tang.kenneth720@gmail.com> Date: Thu, 19 Sep 2024 16:50:18 -0700 Subject: [PATCH 01/21] Copied work from dev branch to main --- .env.example | 5 +- autoRSA.py | 15 +- fidelityAPI.py | 1221 ++++++++++++++++++++++++------------------------ 3 files changed, 622 insertions(+), 619 deletions(-) mode change 100644 => 100755 .env.example mode change 100644 => 100755 autoRSA.py mode change 100644 => 100755 fidelityAPI.py diff --git a/.env.example b/.env.example old mode 100644 new mode 100755 index df295302..bf4721a7 --- a/.env.example +++ b/.env.example @@ -28,7 +28,10 @@ CHASE= FENNEL= # Fidelity -# FIDELITY=FIDELITY_USERNAME:FIDELITY_PASSWORD +# If 2fa is not enabled: +# FIDELITY=FIDELITY_USERNAME:FIDELITY_PASSWORD:NA +# If 2fa is enabled: +# FIDELITY=FIDELITY_USERNAME:FIDELITY_PASSWORD:FIDELITY_TOTP_SECRET FIDELITY= # Firstrade diff --git a/autoRSA.py b/autoRSA.py old mode 100644 new mode 100755 index f211a35c..994b38e5 --- a/autoRSA.py +++ b/autoRSA.py @@ -113,15 +113,8 @@ def fun_run(orderObj: stockOrder, command, botObj=None, loop=None): try: # Initialize broker fun_name = broker + first_command - if broker.lower() == "fidelity": - # Fidelity requires docker mode argument, botObj, and loop - orderObj.set_logged_in( - globals()[fun_name]( - DOCKER=DOCKER_MODE, botObj=botObj, loop=loop - ), - broker, - ) - elif broker.lower() == "tornado": + + if broker.lower() == "tornado": # Requires docker mode argument and loop orderObj.set_logged_in( globals()[fun_name](DOCKER=DOCKER_MODE, loop=loop), @@ -132,7 +125,7 @@ def fun_run(orderObj: stockOrder, command, botObj=None, loop=None): orderObj.set_logged_in( globals()[fun_name](botObj=botObj, loop=loop), broker ) - elif broker.lower() in ["chase", "vanguard"]: + elif broker.lower() in ["chase", "vanguard", "fidelity"]: fun_name = broker + "_run" # PLAYWRIGHT_BROKERS have to run all transactions with one function th = ThreadHandler( @@ -155,7 +148,7 @@ def fun_run(orderObj: stockOrder, command, botObj=None, loop=None): orderObj.set_logged_in(globals()[fun_name](), broker) print() - if broker.lower() not in ["chase", "vanguard"]: + if broker.lower() not in ["chase", "vanguard", "fidelity"]: # Verify broker is logged in orderObj.order_validate(preLogin=False) logged_in_broker = orderObj.get_logged_in(broker) diff --git a/fidelityAPI.py b/fidelityAPI.py old mode 100644 new mode 100755 index 8f1c652c..01faa032 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -1,20 +1,22 @@ -# Nelson Dane +# Kenneth Tang # API to Interface with Fidelity -# Uses headless Selenium +# Uses headless Playwright +# 2024/09/18 +# Adapted from Nelson Dane's Selenium based code and created with the help of playwright codegen import asyncio import datetime import os import traceback from time import sleep +import json +import pyotp from dotenv import load_dotenv -from selenium import webdriver -from selenium.common.exceptions import NoSuchElementException, TimeoutException -from selenium.webdriver import Keys -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions -from selenium.webdriver.support.wait import WebDriverWait + +from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError +from playwright_stealth import StealthConfig, stealth_sync +import csv from helperAPI import ( Brokerage, @@ -31,627 +33,632 @@ type_slowly, ) +class FidelityAutomation: + def __init__(self, headless=True, title=None, profile_path='.') -> None: + # Setup the webdriver + self.headless: bool = headless + self.title: str = title + self.profile_path: str = profile_path + self.stealth_config = StealthConfig( + navigator_languages=False, + navigator_user_agent=False, + navigator_vendor=False) + self.getDriver() + + def getDriver(self): + ''' + Initializes the playwright webdriver for use in subsequent functions. + Creates and applies stealth settings to playwright context wrapper. + ''' + # Set the context wrapper + self.playwright = sync_playwright().start() + + + # Create or load cookies + self.profile_path = os.path.abspath(self.profile_path) + if self.title is not None: + self.profile_path = os.path.join( + self.profile_path, f"Fidelity_{self.title}.json" + ) + else: + self.profile_path = os.path.join(self.profile_path, "Fidelity.json") + if not os.path.exists(self.profile_path): + os.makedirs(os.path.dirname(self.profile_path), exist_ok=True) + with open(self.profile_path, "w") as f: + json.dump({}, f) + + # Launch the browser + self.browser = self.playwright.firefox.launch(headless=self.headless, args=["--disable-webgl", "--disable-software-rasterizer"]) + + self.context = self.browser.new_context(storage_state=self.profile_path if self.title is not None else None) + self.page = self.context.new_page() + # Apply stealth settings + stealth_sync(self.page, self.stealth_config) + + def save_storage_state(self): + """ + Saves the storage state of the browser to a file. + + This method saves the storage state of the browser to a file so that it can be restored later. + + Args: + filename (str): The name of the file to save the storage state to. + """ + storage_state = self.page.context.storage_state() + with open(self.profile_path, "w") as f: + json.dump(storage_state, f) + + def close_browser(self): + ''' + Closes the playwright browser + Use when you are completely done with this class + ''' + # Save cookies + self.save_storage_state() + # Close context before browser as directed by documentation + self.context.close() + self.browser.close() + # Stop the instance of playwright + self.playwright.stop() + + def login(self, username: str, password: str, totp_secret: str=None) -> bool: + ''' + Logs into fidelity using the supplied username and password. + + Returns: + True, True: If completely logged in, return (True, True) + True, False: If 2FA is needed, this function will return (True, False) which signifies that the + initial login attempt was successful but further action is needed to finish logging in. + False, False: Initial login attempt failed. + + ''' + try: + # Go to the login page + self.page.goto("https://digital.fidelity.com/prgw/digital/login/full-page") + + # Login page + self.page.get_by_label("Username", exact=True).click() + self.page.get_by_label("Username", exact=True).fill(username) + self.page.get_by_label("Password", exact=True).click() + self.page.get_by_label("Password", exact=True).fill(password) + self.page.get_by_role("button", name="Log in").click() + try: + # See if we got to the summary page + self.page.wait_for_url('https://digital.fidelity.com/ftgw/digital/portfolio/summary', timeout=5000) + # Got to the summary page, return True + return (True, True) + except PlaywrightTimeoutError: + # Didn't get there yet, continue trying + pass + + # Check to see if blank + totp_secret=(None if totp_secret == "NA" else totp_secret) + + # If we hit the 2fA page after trying to login + if 'login' in self.page.url: + + # If TOTP secret is provided, we are will use the TOTP key. See if authenticator code is present + if totp_secret != None and self.page.get_by_role("heading", name="Enter the code from your").is_visible(): + # Get authenticator code + code = pyotp.TOTP(totp_secret).now() + # Enter the code + self.page.get_by_placeholder("XXXXXX").click() + self.page.get_by_placeholder("XXXXXX").fill(code) + + # Prevent future OTP requirements + self.page.locator("label").filter(has_text="Don't ask me again on this").check() + assert self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked() + + # Log in with code + self.page.get_by_role("button", name="Continue").click() + + # See if we got to the summary page + self.page.wait_for_url('https://digital.fidelity.com/ftgw/digital/portfolio/summary', timeout=5000) + # Got to the summary page, return True + return (True, True) + + + # If the authenticator code is the only way but we don't have the secret, return error + if self.page.get_by_text("Enter the code from your authenticator app This security code will confirm the").is_visible(): + raise Exception("Fidelity needs code from authenticator app but TOTP secret is not provided") + + # If the app push notification page is present + if self.page.get_by_role("link", name="Try another way").is_visible(): + self.page.locator("label").filter(has_text="Don't ask me again on this").check() + assert self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked() + + # Click on alternate verification method to get OTP via text + self.page.get_by_role("link", name="Try another way").click() + + # Press the Text me button + self.page.get_by_role("button", name="Text me the code").click() + self.page.get_by_placeholder("XXXXXX").click() + + return (True, False) + + elif 'summary' not in self.page.url: + raise Exception("Cannot get to login page. Maybe other 2FA method present") + + # Some other case that isn't a log in. This shouldn't be reached under normal circumstances + return (False, False) + + except PlaywrightTimeoutError: + print("Timeout waiting for login page to load or navigate.") + return (False, False) + except Exception as e: + print(f"An error occurred: {str(e)}") + traceback.print_exc() + return (False, False) + + def login_2FA(self, code): + ''' + Completes the 2FA portion of the login using a phone text code. + + Returns: + True: bool: If login succeeded, return true. + False: bool: If login failed, return false. + ''' + try: + self.page.get_by_placeholder("XXXXXX").fill(code) + + # Prevent future OTP requirements + self.page.locator("label").filter(has_text="Don't ask me again on this").check() + assert self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked() + self.page.get_by_role("button", name="Submit").click() + + self.page.wait_for_url('https://digital.fidelity.com/ftgw/digital/portfolio/summary', timeout=5000) + return True + + except PlaywrightTimeoutError: + print("Timeout waiting for login page to load or navigate.") + return False + except Exception as e: + print(f"An error occurred: {str(e)}") + traceback.print_exc() + return False + + def getAccountInfo(self): + ''' + Gets account numbers, account names, and account totals by downloading the csv of positions from fidelity. + The file path of the downloaded csv is saved to self.positions_csv and can be deleted later. + + Post Conditions: + self.positions_csv: The absolute file path to the downloaded csv file of positions for all accounts + Returns: + account_dict: dict: A dictionary using account numbers as keys. Each key holds a dict which has + 'balance': float: Total account balance + 'type': str: The account nickname or default name + ''' + # Go to positions page + self.page.goto('https://digital.fidelity.com/ftgw/digital/portfolio/positions') + + # Download the positions as a csv + with self.page.expect_download() as download_info: + self.page.get_by_label("Download Positions").click() + download = download_info.value + cur = os.getcwd() + self.positions_csv = os.path.join(cur, download.suggested_filename) + # Create a copy to work on with the proper file name known + download.save_as(self.positions_csv) + + + + + + + + csv_file = open(self.positions_csv, newline='', encoding='utf-8-sig') + + reader = csv.DictReader(csv_file) + # Ensure all fields we want are present + required_elements = ['Account Number', 'Account Name', 'Symbol', 'Description', 'Quantity', 'Last Price', 'Current Value'] + intersection_set = set(reader.fieldnames).intersection(set(required_elements)) + if len(intersection_set) != len(required_elements): + raise Exception('Not enough elements in fidelity positions csv') + + self.account_dict = {} + for row in reader: + # Last couple of rows have some disclaimers, filter those out + if row['Account Number'] != None and 'and' in str(row['Account Number']): + break + # Get the value and remove '$' from it + val = str(row['Current Value']).replace('$','') + # Get the last price + last_price = str(row['Last Price']).replace('$', '') + # Get quantity + quantity = row['Quantity'] + # Get ticker + ticker = str(row['Symbol']) + + # Don't include this if present + if 'Pending' in ticker: + continue + # If the value isn't present, move to next row + if len(val) == 0: + continue + # If the last price isn't available, just use the current value + if len(last_price) == 0: + last_price = val + # If the quantity is missing, just use 1 + if len(quantity) == 0: + quantity = 1 + + # If the account number isn't populated yet, add it + if row['Account Number'] not in self.account_dict: + # Add retrieved info. + # Yeah I know is kinda messy and hard to think about but it works + # Just need a way to store all stocks with the account number + # 'stocks' is a list of dictionaries. Each ticker gets its own index and is described by a dictionary + self.account_dict[row['Account Number']] = {'balance': float(val), 'type': row['Account Name'], + 'stocks': [{'ticker': ticker, 'quantity': quantity, 'last_price': last_price, 'value': val}] + } + # If it is present, add to it + else: + self.account_dict[row['Account Number']]['stocks'].append({'ticker': ticker, 'quantity': quantity, 'last_price': last_price, 'value': val}) + self.account_dict[row['Account Number']]['balance'] += float(val) + + # Close the file + csv_file.close() + os.remove(self.positions_csv) + + return self.account_dict + + def transaction(self, stock: str, quantity: float, action: str, account: str, dry: bool=True) -> bool: + ''' + Process an order (transaction) using the dedicated trading page. + NOTE: If you use this function repeatedly but change the stock between any call, + RELOAD the page before calling this + + For buying: + If the price of the security is below $1, it will choose limit order and go off of the last price + a little + For selling: + Places a market order for the security + + Parameters: + stock: str: The ticker that represents the security to be traded + quantity: float: The amount to buy or sell of the security + action: str: This must be 'buy' or 'sell'. It can be in any case state (i.e. 'bUY' is still valid) + account: str: The account number to trade under. + dry: bool: True for dry (test) run, False for real run. + + Returns: + (Success: bool, Error_message: str) If the order was successfully placed or tested (for dry runs) then True is + returned and Error_message will be None. Otherwise, False will be returned and Error_message will not be None + ''' + try: + # Go to the trade page + if self.page.url != 'https://digital.fidelity.com/ftgw/digital/trade-equity/index/orderEntry': + self.page.goto('https://digital.fidelity.com/ftgw/digital/trade-equity/index/orderEntry') + + # Click on the drop down + self.page.query_selector("#dest-acct-dropdown").click() + + if not self.page.get_by_role("option").filter(has_text=account.upper()).is_visible(): + # Reload the page and hit the drop down again + # This is to prevent a rare case where the drop down is empty + print("Reloading...") + self.page.reload() + # Click on the drop down + self.page.query_selector("#dest-acct-dropdown").click() + # Find the account to trade under + self.page.get_by_role("option").filter(has_text=account.upper()).click() + + # Enter the symbol + self.page.get_by_label("Symbol").click() + # Fill in the ticker + self.page.get_by_label("Symbol").fill(stock) + # Find the symbol we wanted and click it + self.page.get_by_label("Symbol").press("Enter") + + # Wait for quote panel to show up + self.page.locator("#quote-panel").wait_for(timeout=2000) + last_price = self.page.query_selector("#eq-ticket__last-price > span.last-price").text_content() + last_price = last_price.replace('$','') + + # Ensure we are in the expanded ticket + if self.page.get_by_role("button", name="View expanded ticket").is_visible(): + self.page.get_by_role("button", name="View expanded ticket").click() + # Wait for it to take effect + self.page.get_by_role("button", name="Calculate shares").wait_for(timeout=2000) -def fidelity_error(driver: webdriver, error: str): - print(f"Fidelity Error: {error}") - driver.save_screenshot(f"fidelity-error-{datetime.datetime.now()}.png") - print(traceback.format_exc()) + # When enabling extended hour trading + extended = False + precision = 3 + # Enable extended hours trading if available + if self.page.get_by_text("Extended hours trading").is_visible(): + if self.page.get_by_text("Extended hours trading: OffUntil 8:00 PM ET").is_visible(): + self.page.get_by_text("Extended hours trading: OffUntil 8:00 PM ET").check() + extended = True + precision = 2 -def javascript_get_classname(driver: webdriver, className) -> list: - script = f""" - var accounts = document.getElementsByClassName("{className}"); - var account_list = []; - for (var i = 0; i < accounts.length; i++) {{ - account_list.push(accounts[i].textContent.trim()); - }} - return account_list; - """ - text = driver.execute_script(script) - sleep(1) - return text + + # Press the buy or sell button. Title capitalizes the first letter so 'buy' -> 'Buy' + self.page.locator("#order-action-input-container").click() + self.page.get_by_role("option", name=action.lower().title(), exact=True).wait_for() + self.page.get_by_role("option", name=action.lower().title(), exact=True).click() + + # Press the shares text box + self.page.locator("#eqt-mts-stock-quatity div").filter(has_text="Quantity").click() + self.page.get_by_label("you own").fill(str(quantity)) -def fidelity_init(FIDELITY_EXTERNAL=None, DOCKER=False, botObj=None, loop=None): + # If it should be limit + if float(last_price) < 1 or extended: + # Buy above + if action.lower() == 'buy': + difference_price = 0.01 if float(last_price) > 0.1 else 0.0001 + wanted_price = round(float(last_price) + difference_price, precision) + # Sell below + else: + difference_price = 0.01 if float(last_price) > 0.1 else 0.0001 + wanted_price = round(float(last_price) - difference_price, precision) + + # Click on the limit default option when in extended hours + self.page.locator("#order-type-container-id").click() + self.page.get_by_role("option", name="Limit", exact=True).click() + # Enter the limit price + self.page.get_by_text("Limit price").click() + self.page.get_by_label("Limit price").fill(str(wanted_price)) + # Otherwise its market + else: + # Click on the market + self.page.locator("#order-type-container-id").click() + self.page.get_by_role("option", name="Market", exact=True).click() + + # Continue with the order + self.page.get_by_role("button", name="Preview order").click() + + # If error occurred + try: + self.page.get_by_role("button", name="Place order clicking this").wait_for(timeout=4000, state='visible') + except PlaywrightTimeoutError: + # Error must be present (or really slow page for some reason) + # Try to report on error + error_message = 'Could not retrieve error message from popup' + filtered_error = '' + try: + error_message = self.page.get_by_label("Error").locator("div").filter(has_text="critical").nth(2).text_content() + self.page.get_by_role("button", name="Close dialog").click() + except: + pass + # Return with error and trim it down (it contains many spaces for some reason) + if error_message != None: + for i, character in enumerate(error_message): + if i == 0 or (character == ' ' and error_message[i - 1] == ' ') or character == '\n' or character == '\t': + continue + filtered_error += character + filtered_error = filtered_error.replace('critical', '').strip() + error_message = filtered_error.replace('\n', '') + return (False, error_message) + + # If no error occurred, continue with checking and buy/sell + try: + assert self.page.locator("preview").filter(has_text=account.upper()).is_visible() + assert self.page.get_by_text(f"Symbol{stock.upper()}", exact=True).is_visible() + assert self.page.get_by_text(f"Action{action.lower().title()}").is_visible() + assert self.page.get_by_text(f"Quantity{quantity}").is_visible() + except AssertionError: + return (False, 'Order preview is not what is expected') + + # If its a real run + if not dry: + self.page.get_by_role("button", name="Place order clicking this").click() + try: + # See that the order goes through + self.page.get_by_text("Order received").wait_for(timeout=5000, state='visible') + # If no error, return with success + return (True, None) + except PlaywrightTimeoutError: + # Order didn't go through for some reason, go to the next and say error + return (False, 'Order failed to complete') + # If its a dry run, report back success + return (True, None) + except PlaywrightTimeoutError: + return (False, 'Driver timed out. Order not complete') + except Exception as e: + return (False, e) + +def fidelity_run(orderObj: stockOrder, command=None, botObj=None, loop=None, FIDELITY_EXTERNAL=None): + ''' + Entry point from main function. Gathers credentials and go through commands for + each set of credentials found in the FIDELITY env variable + + Returns: + None + ''' # Initialize .env file load_dotenv() - # Import Fidelity account + # Import Chase account if not os.getenv("FIDELITY") and FIDELITY_EXTERNAL is None: print("Fidelity not found, skipping...") return None - accounts = ( - os.environ["FIDELITY"].strip().split(",") - if FIDELITY_EXTERNAL is None - else FIDELITY_EXTERNAL.strip().split(",") - ) - fidelity_obj = Brokerage("Fidelity") - # Init webdriver + accounts = (os.environ["FIDELITY"].strip().split(",") + if FIDELITY_EXTERNAL is None + else FIDELITY_EXTERNAL.strip().split(",")) + # Get headless flag + headless = os.getenv("HEADLESS", "true").lower() == "true" + # Set the functions to be run + _, second_command = command + + # For each set of login info, i.e. separate chase accounts for account in accounts: + # Start at index 1 and go to how many logins we have index = accounts.index(account) + 1 - name = f"Fidelity {index}" + name = f'Fidelity {index}' + # Receive the chase broker class object and the AllAccount object related to it + fidelityobj = fidelity_init( + account=account, + name=name, + headless=headless, + botObj=botObj, + loop=loop, + ) + if fidelityobj is not None: + # Store the Brokerage object for fidelity under 'fidelity' in the orderObj + orderObj.set_logged_in(fidelityobj, "fidelity") + if second_command == "_holdings": + fidelity_holdings(fidelityobj, name, loop=loop) + # Only other option is _transaction + else: + fidelity_transaction(fidelityobj, name, orderObj, loop=loop) + return None + +def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None): + ''' + Log into fidelity. Creates a fidelity brokerage object and a FidelityAutomation object. + The FidelityAutomation object is stored within the brokerage object and some account information + is gathered. + + Post conditions: Logs into fidelity using the supplied credentials + + Returns: + fidelity_obj: Brokerage: A fidelity brokerage object that holds information on the account + and the webdriver to use for further actions + ''' + + # Log into Fidelity account + print('Logging into Fidelity...') + + # Create brokerage class object and call it Fidelity + fidelity_obj = Brokerage('Fidelity') + + try: + # Split the login into into separate items account = account.split(":") - try: - print("Logging in to Fidelity...") - driver = getDriver(DOCKER) - if driver is None: - raise Exception("Error: Unable to get driver") - # Log in to Fidelity account - load_cookies(driver, filename=f"fidelity{index}.pkl", path="./creds/") - driver.get( - "https://digital.fidelity.com/prgw/digital/login/full-page?AuthRedUrl=digital.fidelity.com/ftgw/digital/portfolio/summary" - ) - # Wait for page load - WebDriverWait(driver, 20).until(check_if_page_loaded) - # Loop to refresh the login page in case it does not load correctly. aka (Shenanigans) - for i in range(3): - # Fidelity has different login views, so check for both - try: - WebDriverWait(driver, 10).until( - expected_conditions.element_to_be_clickable( - (By.CSS_SELECTOR, "#dom-username-input") - ) - ) - username_selector = "#dom-username-input" - password_selector = "#dom-pswd-input" - login_btn_selector = "#dom-login-button > div" - break - except TimeoutException: - pass - try: - WebDriverWait(driver, 10).until( - expected_conditions.element_to_be_clickable( - (By.CSS_SELECTOR, "#userId-input") - ) - ) - username_selector = "#userId-input" - password_selector = "#password" - login_btn_selector = "#fs-login-button" - break - except TimeoutException: - pass - driver.refresh() - if i == 2: - raise Exception("Failed to load login page") - # Type in username and password and click login - WebDriverWait(driver, 10).until( - expected_conditions.element_to_be_clickable( - (By.CSS_SELECTOR, username_selector) - ) - ) - username_field = driver.find_element( - by=By.CSS_SELECTOR, value=username_selector - ) - type_slowly(username_field, account[0]) - password_field = driver.find_element( - by=By.CSS_SELECTOR, value=password_selector - ) - type_slowly(password_field, account[1]) - driver.find_element(by=By.CSS_SELECTOR, value=login_btn_selector).click() - WebDriverWait(driver, 10).until(check_if_page_loaded) - sleep(3) - try: - # Look for: Sorry, we can't complete this action right now. Please try again. - go_back_selector = "#dom-sys-err-go-to-login-button > span > s-slot > s-assigned-wrapper" - WebDriverWait(driver, 10).until( - expected_conditions.element_to_be_clickable( - (By.CSS_SELECTOR, go_back_selector) - ), - ).click() - username_field = driver.find_element( - by=By.CSS_SELECTOR, value=username_selector - ) - type_slowly(username_field, account[0]) - password_field = driver.find_element( - by=By.CSS_SELECTOR, value=password_selector - ) - type_slowly(password_field, account[1]) - driver.find_element( - by=By.CSS_SELECTOR, value=login_btn_selector - ).click() - except TimeoutException: - pass - # Check for mobile 2fa page - try: - try_another_way = "a#dom-try-another-way-link" - WebDriverWait(driver, 10).until( - expected_conditions.element_to_be_clickable( - (By.CSS_SELECTOR, try_another_way) - ), - ).click() - except TimeoutException: - pass - # Check for normal 2fa page - try: - text_me_button = "//*[@id='dom-channel-list-primary-button' and contains(string(.), 'Text me the code')]" # Make sure it doesn't duplicate from mobile page - WebDriverWait(driver, 10).until( - expected_conditions.element_to_be_clickable( - (By.XPATH, text_me_button) - ), - ).click() - # Make sure the next page loads fully - code_field = "#dom-otp-code-input" - WebDriverWait(driver, 10).until( - expected_conditions.visibility_of_element_located( - (By.CSS_SELECTOR, code_field) - ) - ) - # Sometimes codes take a long time to arrive - timeout = 300 # 5 minutes - if botObj is not None and loop is not None: - sms_code = asyncio.run_coroutine_threadsafe( - getOTPCodeDiscord(botObj, name, timeout=timeout, loop=loop), - loop, - ).result() - if sms_code is None: - raise Exception("No SMS code found") - else: - sms_code = input("Enter security code: ") - - code_field = driver.find_element(by=By.CSS_SELECTOR, value=code_field) - code_field.send_keys(str(sms_code)) - remember_device_checkbox = "#dom-trust-device-checkbox + label" - driver.find_element(By.CSS_SELECTOR, remember_device_checkbox).click() - continue_btn_selector = "#dom-otp-code-submit-button" - driver.find_element(By.CSS_SELECTOR, continue_btn_selector).click() - except TimeoutException: - pass - # Wait for page to load to summary page - if "summary" not in driver.current_url: - if "errorpage" in driver.current_url.lower(): - raise Exception( - f"{name}: Login Failed. Got Error Page: Current URL: {driver.current_url}" - ) - print("Waiting for portfolio page to load...") - WebDriverWait(driver, 30).until( - expected_conditions.url_contains("summary") - ) - # Make sure fidelity site is not in old view - try: - if "digital" not in driver.current_url: - print(f"Old view detected: {driver.current_url}") - driver.find_element(by=By.CSS_SELECTOR, value="#optout-btn").click() - WebDriverWait(driver, 10).until(check_if_page_loaded) - # Wait for page to be in new view - if "digital" not in driver.current_url: - WebDriverWait(driver, 60).until( - expected_conditions.url_contains("digital") - ) - WebDriverWait(driver, 10).until(check_if_page_loaded) - print("Disabled old view!") - except (TimeoutException, NoSuchElementException): - print( - "Failed to disable old view! This might cause issues but maybe not..." - ) - sleep(3) - fidelity_obj.set_logged_in_object(name, driver) - # Get account numbers, types, and balances - account_dict = fidelity_account_info(driver) - if account_dict is None: - raise Exception(f"{name}: Error getting account info") - for acct in account_dict: + # Create a Fidelity browser object + fidelity_browser = FidelityAutomation(headless=headless, + title=name, + profile_path="./creds") + + # Log into fidelity + step_1, step_2 = fidelity_browser.login(account[0], account[1], account[2]) + # If 2FA is present, ask for code + if step_1 and not step_2: + if botObj is None and loop is None: + fidelity_browser.login_2FA(input('Enter code: ')) + else: + # Should wait for 60 seconds before timeout + sms_code = asyncio.run_coroutine_threadsafe( + getOTPCodeDiscord(botObj, name, code_len=8, loop=loop), loop + ).result() + if sms_code is None: + raise Exception(f"{name} code not received in time...", loop) + fidelity_browser.login_2FA(sms_code) + elif not step_1: + raise Exception(f"{name}: Login Failed. Got Error Page: Current URL: {fidelity_browser.page.url}") + + # By this point, we should be logged in so save the driver + fidelity_obj.set_logged_in_object(name, fidelity_browser) + + # Getting account numbers, names, and balances + account_dict = fidelity_browser.getAccountInfo() + + if account_dict is None: + raise Exception(f'{name}: Error getting account info') + # Set info into fidelity brokerage object + for acct in account_dict: fidelity_obj.set_account_number(name, acct) fidelity_obj.set_account_type(name, acct, account_dict[acct]["type"]) fidelity_obj.set_account_totals( name, acct, account_dict[acct]["balance"] ) - save_cookies(driver, filename=f"fidelity{index}.pkl", path="./creds/") - print(f"Logged in to {name}!") - except Exception as e: - fidelity_error(driver, e) - driver.close() - driver.quit() - return None - return fidelity_obj - - -def fidelity_account_info(driver: webdriver) -> dict | None: - try: - # Get account holdings - driver.get("https://digital.fidelity.com/ftgw/digital/portfolio/positions") - # Wait for page load - WebDriverWait(driver, 10).until(check_if_page_loaded) - # Uncheck the "Pin Symbol column to left" setting - WebDriverWait(driver, 10).until( - expected_conditions.element_to_be_clickable( - (By.ID, "posweb-grid_top-settings-button") - ) - ).click() - checkbox = driver.find_element( - By.ID, "posweb-settings-modal-pinned-symbol-checkbox" - ) - if checkbox.is_selected(): - driver.execute_script("arguments[0].click();", checkbox) - WebDriverWait(driver, 10).until( - expected_conditions.element_to_be_clickable( - (By.XPATH, "//*[text()='Apply']/parent::*/parent::*") - ) - ).click() - # Get account numbers via javascript - WebDriverWait(driver, 10).until( - expected_conditions.presence_of_element_located( - (By.CLASS_NAME, "acct-selector__acct-num") - ) - ) - account_numbers = javascript_get_classname(driver, "acct-selector__acct-num") - # Get account balances via javascript - account_values = javascript_get_classname(driver, "acct-selector__acct-balance") - # Get account names via javascript - account_types = javascript_get_classname(driver, "acct-selector__acct-name") - # Make sure all lists are the same length - if not ( - len(account_numbers) == len(account_values) - and len(account_numbers) == len(account_types) - ): - shortest = min( - len(account_numbers), len(account_values), len(account_types) - ) - account_numbers = account_numbers[:shortest] - account_values = account_values[:shortest] - account_types = account_types[:shortest] - print( - f"Warning: Account numbers, values, and types are not the same length! Using shortest length: {shortest}" - ) - # Construct dictionary of account numbers and balances - account_dict = {} - for i, account in enumerate(account_numbers): - av = ( - account_values[i] - .replace(" ", "") - .replace("$", "") - .replace(",", "") - .replace("»", "") - .replace("‡", "") - .replace("balance:", "") - ) - account_dict[account] = { - "balance": float(av), - "type": account_types[i], - } - return account_dict + print(f"Logged in to {name}!") + return fidelity_obj + except Exception as e: - fidelity_error(driver, e) + print(f"Error logging in to Fidelity: {e}") + print(traceback.format_exc()) return None +def fidelity_holdings(fidelity_o: Brokerage, name: str, loop=None): + ''' + Retrieves the holdings per account by reading from the previously downloaded positions csv file. + Prints holdings for each account and provides a summary if the user has more than 5 accounts. -def fidelity_holdings(fidelity_o: Brokerage, loop=None): - for key in fidelity_o.get_account_numbers(): - driver: webdriver = fidelity_o.get_logged_in_objects(key) - for account in fidelity_o.get_account_numbers(key): - try: - driver.get( - f"https://digital.fidelity.com/ftgw/digital/portfolio/positions#{account}" - ) - sleep(2) - holdings_rows = driver.find_elements( - By.XPATH, - "//*[@id='posweb-grid']//div[contains(@class, 'posweb-row-position')]", - ) - for row in holdings_rows: - try: - stock_ticker = row.find_element( - By.XPATH, ".//div[contains(@col-id, 'sym')]//button" - ).text - if stock_ticker == "Cash": - continue - price_raw = row.find_element( - By.XPATH, - ".//div[@col-id='lstPrc']//span[@class='ag-cell-value']", - ).text - quantity_raw = row.find_element( - By.XPATH, - ".//div[@col-id='qty']//span[@class='ag-cell-value']", - ).text - # Clean up price and quantity - invalid_strings = ["n/a", ""] - if price_raw.lower() not in invalid_strings: - price = round( - float( - price_raw.replace("$", "").replace(",", "").strip() - ), - 2, - ) - else: - price = "N/A" - if quantity_raw.lower() not in invalid_strings: - quantity = float(quantity_raw.replace(",", "").strip()) - else: - quantity = "N/A" - fidelity_o.set_holdings( - key, account, stock_ticker, quantity, price - ) - except Exception as e: - print(f"Unexpected error processing row, skipping it: {str(e)}") - continue - except Exception as e: - fidelity_error(driver, e) - continue + Parameters: + fidelity_o: Brokerage: The brokerage object that contains account numbers and the + FidelityAutomation class object that is logged into fidelity + name: str: The name of this brokerage object (ex: Fidelity 1) + loop: AbstractEventLoop: The event loop to be used + + Returns: + None + ''' + + # Get the browser back from the fidelity object + fidelity_browser: FidelityAutomation = fidelity_o.get_logged_in_objects(name) + unique_stocks = {} + account_dict = fidelity_browser.account_dict + for account_number in account_dict: + + for d in account_dict[account_number]['stocks']: + # Append the ticker to the appropriate account + fidelity_o.set_holdings(parent_name=name, + account_name=account_number, + stock=d['ticker'], + quantity=d['quantity'], + price=d['last_price']) + # Create a list of unique holdings + if d['ticker'] not in unique_stocks: + unique_stocks[d['ticker']] = {'quantity': float(d['quantity']), 'last_price': d['last_price'], 'value': float(d['value'])} + else: + unique_stocks[d['ticker']]['quantity'] += float(d['quantity']) + unique_stocks[d['ticker']]['value'] += float(d['value']) + + # Print to console and to discord printHoldings(fidelity_o, loop) - killSeleniumDriver(fidelity_o) - - -def fidelity_transaction(fidelity_o: Brokerage, orderObj: stockOrder, loop=None): - print() - print("==============================") - print("Fidelity") - print("==============================") - print() - new_style = False - for s in orderObj.get_stocks(): - for key in fidelity_o.get_account_numbers(): - printAndDiscord( - f"{key}: {orderObj.get_action()}ing {orderObj.get_amount()} of {s}", + + # Create a summary of holdings + if len(account_dict.keys()) > 5: + summary = '' + for stock in unique_stocks: + summary += f"{stock}: {round(unique_stocks[stock]['quantity'], 2)} @ {unique_stocks[stock]['last_price']} = {round(unique_stocks[stock]['value'], 2)}\n" + printAndDiscord(f'Summary of holdings: \n{summary}', loop) + + # Close browser + fidelity_browser.close_browser() + +def fidelity_transaction(fidelity_o: Brokerage, name: str, orderObj: stockOrder, loop=None): + ''' + Using the Brokerage object, call FidelityAutomation.transaction() and process its' return + + Parameters: + fidelity_o: Brokerage: The brokerage object that contains account numbers and the + FidelityAutomation class object that is logged into fidelity + name: str: The name of this brokerage object (ex: Fidelity 1) + orderObj: stockOrder: The stock object used for storing stocks to buy or sell + loop: AbstractEventLoop: The event loop to be used + + Returns: + None + ''' + + # Get the driver + fidelity_browser: FidelityAutomation = fidelity_o.get_logged_in_objects(name) + # Go trade + for stock in orderObj.get_stocks(): + # Say what we are doing + printAndDiscord( + f"{name}: {orderObj.get_action()}ing {orderObj.get_amount()} of {stock}", loop, ) - driver = fidelity_o.get_logged_in_objects(key) - # Go to trade page - driver.get( - "https://digital.fidelity.com/ftgw/digital/trade-equity/index/orderEntry" - ) - # Wait for page to load - WebDriverWait(driver, 20).until(check_if_page_loaded) - sleep(3) - # Get number of accounts - try: - accounts_dropdown = driver.find_element( - by=By.CSS_SELECTOR, value="#dest-acct-dropdown" - ) - driver.execute_script("arguments[0].click();", accounts_dropdown) - WebDriverWait(driver, 10).until( - expected_conditions.presence_of_element_located( - (By.CSS_SELECTOR, "#ett-acct-sel-list") - ) - ) - test = driver.find_element( - by=By.CSS_SELECTOR, value="#ett-acct-sel-list" - ) - accounts_list = test.find_elements(by=By.CSS_SELECTOR, value="li") - number_of_accounts = len(accounts_list) - # Click a second time to clear the account list - driver.execute_script("arguments[0].click();", accounts_dropdown) - except Exception as e: - fidelity_error(driver, f"No accounts found in dropdown: {e}") - killSeleniumDriver(fidelity_o) - return None - # Complete on each account - # Because of stale elements, we need to re-find the elements each time - for x in range(number_of_accounts): - try: - # Select account - accounts_dropdown_in = driver.find_element( - by=By.CSS_SELECTOR, value="#eq-ticket-account-label" - ) - driver.execute_script("arguments[0].click();", accounts_dropdown_in) - WebDriverWait(driver, 10).until( - expected_conditions.presence_of_element_located( - (By.ID, "ett-acct-sel-list") - ) - ) - test = driver.find_element(by=By.ID, value="ett-acct-sel-list") - accounts_dropdown_in = test.find_elements( - by=By.CSS_SELECTOR, value="li" - ) - account_number = fidelity_o.get_account_numbers(key)[x] - account_label = maskString(account_number) - accounts_dropdown_in[x].click() - sleep(1) - # Type in ticker - ticker_box = driver.find_element( - by=By.CSS_SELECTOR, value="#eq-ticket-dest-symbol" - ) - WebDriverWait(driver, 10).until( - expected_conditions.element_to_be_clickable(ticker_box) - ) - ticker_box.send_keys(s) - ticker_box.send_keys(Keys.RETURN) - sleep(1) - # Check if symbol not found is displayed - try: - driver.find_element( - by=By.CSS_SELECTOR, - value="body > div.app-body > ap122489-ett-component > div > order-entry-base > div > div > div.order-entry__container-content.scroll > div > equity-order-selection > div:nth-child(1) > symbol-search > div > div.eq-ticket--border-top > div > div:nth-child(2) > div > div > div > pvd3-inline-alert > s-root > div > div.pvd-inline-alert__content > s-slot > s-assigned-wrapper", - ) - printAndDiscord(f"{key} Error: Symbol {s} not found", loop) - print() - killSeleniumDriver(fidelity_o) - return None - except Exception: - pass - # Get last price - last_price = driver.find_element( - by=By.CSS_SELECTOR, - value="#eq-ticket__last-price > span.last-price", - ).text - last_price = last_price.replace("$", "") - # If price is under $1, then we have to use a limit order - LIMIT = bool(float(last_price) < 1) - # Figure out whether page is in old or new style - try: - action_dropdown = driver.find_element( - by=By.CSS_SELECTOR, - value="#dest-dropdownlist-button-action", - ) - new_style = True - except NoSuchElementException: - pass - # Set buy/sell - if orderObj.get_action() == "buy": - # buy is default in dropdowns so do not need to click - if new_style: - driver.find_element( - by=By.CSS_SELECTOR, - value="#dest-dropdownlist-button-action", - ).click() - driver.find_element( - by=By.CSS_SELECTOR, - value="#Action0", - ).click() - else: - buy_button = driver.find_element( - by=By.CSS_SELECTOR, - value="#action-buy > s-root > div > label > s-slot > s-assigned-wrapper", - ) - buy_button.click() - else: - if new_style: - action_dropdown = driver.find_element( - by=By.CSS_SELECTOR, - value="#dest-dropdownlist-button-action", - ) - action_dropdown.click() # Action0 - driver.find_element( - by=By.CSS_SELECTOR, - value="#Action1", - ).click() - else: - sell_button = driver.find_element( - by=By.CSS_SELECTOR, - value="#action-sell > s-root > div > label > s-slot > s-assigned-wrapper", - ) - sell_button.click() - # Set amount (and clear previous amount) - amount_box = driver.find_element( - by=By.CSS_SELECTOR, value="#eqt-shared-quantity" - ) - amount_box.clear() - amount_box.send_keys(str(orderObj.get_amount())) - # Set market/limit - if not LIMIT: - if new_style: - driver.find_element( - by=By.CSS_SELECTOR, - value="#dest-dropdownlist-button-ordertype", - ).click() - driver.find_element( - by=By.CSS_SELECTOR, - value="#Order\\ type0", - ).click() - else: - market_button = driver.find_element( - by=By.CSS_SELECTOR, - value="#market-yes > s-root > div > label > s-slot > s-assigned-wrapper", - ) - market_button.click() - else: - if new_style: - driver.find_element( - by=By.CSS_SELECTOR, - value="#dest-dropdownlist-button-ordertype", - ).click() - driver.find_element( - by=By.CSS_SELECTOR, - value="#Order\\ type1", - ).click() - else: - limit_button = driver.find_element( - by=By.CSS_SELECTOR, - value="#market-no > s-root > div > label > s-slot > s-assigned-wrapper", - ) - limit_button.click() - # Set price - difference_price = 0.01 if float(last_price) > 0.1 else 0.0001 - if orderObj.get_action() == "buy": - wanted_price = round( - float(last_price) + difference_price, 3 - ) - else: - wanted_price = round( - float(last_price) - difference_price, 3 - ) - if new_style: - price_box = driver.find_element( - by=By.CSS_SELECTOR, value="#eqt-mts-limit-price" - ) - else: - price_box = driver.find_element( - by=By.CSS_SELECTOR, - value="#eqt-ordsel-limit-price-field", - ) - price_box.clear() - price_box.send_keys(wanted_price) - # Check for margin account - try: - margin_cash = driver.find_element( - by=By.ID, value="tradetype-cash" - ) - margin_cash.click() - print("Margin account found!") - except NoSuchElementException: - pass - # Preview order - WebDriverWait(driver, 10).until(check_if_page_loaded) - sleep(1) - preview_button = driver.find_element( - by=By.CSS_SELECTOR, value="#previewOrderBtn" - ) - preview_button.click() - # Wait for page to load - WebDriverWait(driver, 10).until(check_if_page_loaded) - sleep(3) - # Check for error popup and clear - try: - # Seems Windows is using this right now - error_dismiss = WebDriverWait(driver, 5).until( - expected_conditions.element_to_be_clickable( - ( - By.XPATH, - "(//div[@class='pvd-modal__content']//button)[4]", - ) - ) - ) - except TimeoutException: - # And this for linux? - try: - error_dismiss = WebDriverWait(driver, 5).until( - expected_conditions.element_to_be_clickable( - ( - By.XPATH, - "(//div[@class='pvd-modal__content']//button)[1]", - ) - ) - ) - except TimeoutException: - pass - try: - error_text = driver.find_element( - By.XPATH, - "//div[@class='pvd-inline-alert__content']//div[1]", - ) - error_text = error_text.text - driver.execute_script("arguments[0].click();", error_dismiss) - except (NoSuchElementException, TimeoutException): - pass - # Place order - if not orderObj.get_dry(): - # Check for error popup and clear it if the - # account cannot sell the stock for some reason - try: - place_button = driver.find_element( - by=By.CSS_SELECTOR, value="#placeOrderBtn" - ) - place_button.click() - - # Wait for page to load - WebDriverWait(driver, 10).until(check_if_page_loaded) - sleep(1) - # Send confirmation - printAndDiscord( - f"{key} {account_label}: {orderObj.get_action()} {orderObj.get_amount()} shares of {s}", - loop, - ) - except NoSuchElementException: - printAndDiscord( - f"{key} account {account_label}: {orderObj.get_action()} {orderObj.get_amount()} shares of {s}. DID NOT COMPLETE! \n{error_text}", - loop, - ) - # Send confirmation - else: - printAndDiscord( - f"DRY: {key} account {account_label}: {orderObj.get_action()} {orderObj.get_amount()} shares of {s}", - loop, - ) - sleep(3) - except Exception as err: - fidelity_error(driver, err) - continue - print() - killSeleniumDriver(fidelity_o) + # Reload the page incase we were trading before + fidelity_browser.page.reload() + for account_number in fidelity_o.get_account_numbers(name): + # Go trade for all accounts for that stock + success, error_message = fidelity_browser.transaction(stock, orderObj.get_amount(), orderObj.get_action(), + account_number, orderObj.get_dry()) + # Report error if occurred + if not success: + printAndDiscord(f"{name} account xxxxx{account_number[-4:]}: {orderObj.get_action()} {orderObj.get_amount()} {error_message}", + loop) + # Print test run confirmation if test run + elif success and orderObj.get_dry(): + printAndDiscord(f"DRY: {name} account xxxxx{account_number[-4:]}: {orderObj.get_action()} {orderObj.get_amount()} shares of {stock}", loop) + # Print real run confirmation if real run + elif success and not orderObj.get_dry(): + printAndDiscord(f"{name} account xxxxx{account_number[-4:]}: {orderObj.get_action()} {orderObj.get_amount()} shares of {stock}", loop) + + # Close browser + fidelity_browser.close_browser() \ No newline at end of file From 5824ecdf65ff382a87f0bb4c82c45ea21c23218e Mon Sep 17 00:00:00 2001 From: Kenny <tang.kenneth720@gmail.com> Date: Thu, 19 Sep 2024 17:33:03 -0700 Subject: [PATCH 02/21] Removed unused imports --- fidelityAPI.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index 01faa032..d4f8b46a 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -1,22 +1,21 @@ # Kenneth Tang # API to Interface with Fidelity # Uses headless Playwright -# 2024/09/18 +# 2024/09/19 # Adapted from Nelson Dane's Selenium based code and created with the help of playwright codegen import asyncio -import datetime import os import traceback -from time import sleep import json import pyotp +import csv from dotenv import load_dotenv from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError from playwright_stealth import StealthConfig, stealth_sync -import csv + from helperAPI import ( Brokerage, From e3fec21058bdf843f92e1d60a095fdaadfcfc468 Mon Sep 17 00:00:00 2001 From: Kenny <tang.kenneth720@gmail.com> Date: Thu, 19 Sep 2024 17:43:23 -0700 Subject: [PATCH 03/21] Fixed tiny errors --- fidelityAPI.py | 36 +++++++++--------------------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index d4f8b46a..cc8c70ed 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -19,17 +19,10 @@ from helperAPI import ( Brokerage, - check_if_page_loaded, - getDriver, getOTPCodeDiscord, - killSeleniumDriver, - load_cookies, - maskString, printAndDiscord, printHoldings, - save_cookies, stockOrder, - type_slowly, ) class FidelityAutomation: @@ -52,7 +45,6 @@ def getDriver(self): # Set the context wrapper self.playwright = sync_playwright().start() - # Create or load cookies self.profile_path = os.path.abspath(self.profile_path) if self.title is not None: @@ -131,13 +123,13 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: pass # Check to see if blank - totp_secret=(None if totp_secret == "NA" else totp_secret) + totp_secret = (None if totp_secret == "NA" else totp_secret) # If we hit the 2fA page after trying to login if 'login' in self.page.url: # If TOTP secret is provided, we are will use the TOTP key. See if authenticator code is present - if totp_secret != None and self.page.get_by_role("heading", name="Enter the code from your").is_visible(): + if totp_secret is not None and self.page.get_by_role("heading", name="Enter the code from your").is_visible(): # Get authenticator code code = pyotp.TOTP(totp_secret).now() # Enter the code @@ -156,7 +148,6 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: # Got to the summary page, return True return (True, True) - # If the authenticator code is the only way but we don't have the secret, return error if self.page.get_by_text("Enter the code from your authenticator app This security code will confirm the").is_visible(): raise Exception("Fidelity needs code from authenticator app but TOTP secret is not provided") @@ -239,13 +230,7 @@ def getAccountInfo(self): self.positions_csv = os.path.join(cur, download.suggested_filename) # Create a copy to work on with the proper file name known download.save_as(self.positions_csv) - - - - - - csv_file = open(self.positions_csv, newline='', encoding='utf-8-sig') reader = csv.DictReader(csv_file) @@ -258,10 +243,10 @@ def getAccountInfo(self): self.account_dict = {} for row in reader: # Last couple of rows have some disclaimers, filter those out - if row['Account Number'] != None and 'and' in str(row['Account Number']): + if row['Account Number'] is not None and 'and' in str(row['Account Number']): break # Get the value and remove '$' from it - val = str(row['Current Value']).replace('$','') + val = str(row['Current Value']).replace('$', '') # Get the last price last_price = str(row['Last Price']).replace('$', '') # Get quantity @@ -352,7 +337,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr # Wait for quote panel to show up self.page.locator("#quote-panel").wait_for(timeout=2000) last_price = self.page.query_selector("#eq-ticket__last-price > span.last-price").text_content() - last_price = last_price.replace('$','') + last_price = last_price.replace('$', '') # Ensure we are in the expanded ticket if self.page.get_by_role("button", name="View expanded ticket").is_visible(): @@ -360,7 +345,6 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr # Wait for it to take effect self.page.get_by_role("button", name="Calculate shares").wait_for(timeout=2000) - # When enabling extended hour trading extended = False precision = 3 @@ -371,12 +355,10 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr extended = True precision = 2 - # Press the buy or sell button. Title capitalizes the first letter so 'buy' -> 'Buy' self.page.locator("#order-action-input-container").click() self.page.get_by_role("option", name=action.lower().title(), exact=True).wait_for() self.page.get_by_role("option", name=action.lower().title(), exact=True).click() - # Press the shares text box self.page.locator("#eqt-mts-stock-quatity div").filter(has_text="Quantity").click() @@ -392,7 +374,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr else: difference_price = 0.01 if float(last_price) > 0.1 else 0.0001 wanted_price = round(float(last_price) - difference_price, precision) - + # Click on the limit default option when in extended hours self.page.locator("#order-type-container-id").click() self.page.get_by_role("option", name="Limit", exact=True).click() @@ -422,7 +404,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr except: pass # Return with error and trim it down (it contains many spaces for some reason) - if error_message != None: + if error_message is not None: for i, character in enumerate(error_message): if i == 0 or (character == ' ' and error_message[i - 1] == ' ') or character == '\n' or character == '\t': continue @@ -430,7 +412,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr filtered_error = filtered_error.replace('critical', '').strip() error_message = filtered_error.replace('\n', '') return (False, error_message) - + # If no error occurred, continue with checking and buy/sell try: assert self.page.locator("preview").filter(has_text=account.upper()).is_visible() @@ -439,7 +421,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr assert self.page.get_by_text(f"Quantity{quantity}").is_visible() except AssertionError: return (False, 'Order preview is not what is expected') - + # If its a real run if not dry: self.page.get_by_role("button", name="Place order clicking this").click() From 18ed8b5252a519ca690f5f85ca3bb2dbc819e3c5 Mon Sep 17 00:00:00 2001 From: kennyboy106 <tang.kenneth720@gmail.com> Date: Fri, 20 Sep 2024 22:41:33 -0700 Subject: [PATCH 04/21] Changed transaction function to use css selectors for drop downs. --- fidelityAPI.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index cc8c70ed..c3649db6 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -290,7 +290,7 @@ def getAccountInfo(self): def transaction(self, stock: str, quantity: float, action: str, account: str, dry: bool=True) -> bool: ''' Process an order (transaction) using the dedicated trading page. - NOTE: If you use this function repeatedly but change the stock between any call, + NOTE: If you use this function repeatedly but change the stock between ANY call, RELOAD the page before calling this For buying: @@ -356,13 +356,13 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr precision = 2 # Press the buy or sell button. Title capitalizes the first letter so 'buy' -> 'Buy' - self.page.locator("#order-action-input-container").click() + self.page.query_selector(".eq-ticket-action-label").click() self.page.get_by_role("option", name=action.lower().title(), exact=True).wait_for() self.page.get_by_role("option", name=action.lower().title(), exact=True).click() # Press the shares text box self.page.locator("#eqt-mts-stock-quatity div").filter(has_text="Quantity").click() - self.page.get_by_label("you own").fill(str(quantity)) + self.page.get_by_text("Quantity", exact=True).fill(str(quantity)) # If it should be limit if float(last_price) < 1 or extended: @@ -374,9 +374,9 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr else: difference_price = 0.01 if float(last_price) > 0.1 else 0.0001 wanted_price = round(float(last_price) - difference_price, precision) - + # Click on the limit default option when in extended hours - self.page.locator("#order-type-container-id").click() + self.page.query_selector("#dest-dropdownlist-button-ordertype > span:nth-child(1)").click() self.page.get_by_role("option", name="Limit", exact=True).click() # Enter the limit price self.page.get_by_text("Limit price").click() @@ -393,7 +393,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr # If error occurred try: self.page.get_by_role("button", name="Place order clicking this").wait_for(timeout=4000, state='visible') - except PlaywrightTimeoutError: + except PlaywrightTimeoutError as e: # Error must be present (or really slow page for some reason) # Try to report on error error_message = 'Could not retrieve error message from popup' @@ -404,7 +404,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr except: pass # Return with error and trim it down (it contains many spaces for some reason) - if error_message is not None: + if error_message != None: for i, character in enumerate(error_message): if i == 0 or (character == ' ' and error_message[i - 1] == ' ') or character == '\n' or character == '\t': continue @@ -412,7 +412,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr filtered_error = filtered_error.replace('critical', '').strip() error_message = filtered_error.replace('\n', '') return (False, error_message) - + # If no error occurred, continue with checking and buy/sell try: assert self.page.locator("preview").filter(has_text=account.upper()).is_visible() @@ -421,7 +421,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr assert self.page.get_by_text(f"Quantity{quantity}").is_visible() except AssertionError: return (False, 'Order preview is not what is expected') - + # If its a real run if not dry: self.page.get_by_role("button", name="Place order clicking this").click() @@ -430,13 +430,13 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr self.page.get_by_text("Order received").wait_for(timeout=5000, state='visible') # If no error, return with success return (True, None) - except PlaywrightTimeoutError: + except PlaywrightTimeoutError as e: # Order didn't go through for some reason, go to the next and say error - return (False, 'Order failed to complete') + return (False, f'Order failed to complete') # If its a dry run, report back success return (True, None) - except PlaywrightTimeoutError: - return (False, 'Driver timed out. Order not complete') + except PlaywrightTimeoutError as e: + return (False, f'Driver timed out. Order not complete') except Exception as e: return (False, e) From 3d61c4eacb9685b72739d328556a248f997f61fe Mon Sep 17 00:00:00 2001 From: kennyboy106 <tang.kenneth720@gmail.com> Date: Sat, 21 Sep 2024 08:39:33 -0700 Subject: [PATCH 05/21] Updated fidelity login to account for case where no app is present and no TOTP secret is used --- fidelityAPI.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index c3649db6..6dd948d8 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -160,11 +160,11 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: # Click on alternate verification method to get OTP via text self.page.get_by_role("link", name="Try another way").click() - # Press the Text me button - self.page.get_by_role("button", name="Text me the code").click() - self.page.get_by_placeholder("XXXXXX").click() - - return (True, False) + # Press the Text me button + self.page.get_by_role("button", name="Text me the code").click() + self.page.get_by_placeholder("XXXXXX").click() + + return (True, False) elif 'summary' not in self.page.url: raise Exception("Cannot get to login page. Maybe other 2FA method present") From b8cb7ffbb44e14fa1bce82b7c96f929116938507 Mon Sep 17 00:00:00 2001 From: Nelson Dane <47427072+NelsonDane@users.noreply.github.com> Date: Sat, 21 Sep 2024 18:32:34 -0400 Subject: [PATCH 06/21] add csv to ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7c76335b..abab5024 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*.csv .env did.bin .DS_Store From 6be71d180fcd771831a3b316ed5b4576eac8386b Mon Sep 17 00:00:00 2001 From: Nelson Dane <47427072+NelsonDane@users.noreply.github.com> Date: Sat, 21 Sep 2024 18:32:51 -0400 Subject: [PATCH 07/21] fix na val and no 2fa --- fidelityAPI.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index 6dd948d8..5d56b214 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -260,6 +260,8 @@ def getAccountInfo(self): # If the value isn't present, move to next row if len(val) == 0: continue + elif val.lower() == 'n/a': + val = 0 # If the last price isn't available, just use the current value if len(last_price) == 0: last_price = val @@ -513,7 +515,7 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None profile_path="./creds") # Log into fidelity - step_1, step_2 = fidelity_browser.login(account[0], account[1], account[2]) + step_1, step_2 = fidelity_browser.login(account[0], account[1], account[2] if len(account) > 2 else None) # If 2FA is present, ask for code if step_1 and not step_2: if botObj is None and loop is None: From 62dfb78a3e974b0e89c871f867b472ce3d27f80d Mon Sep 17 00:00:00 2001 From: matthew55 <78285385+matthew55@users.noreply.github.com> Date: Sun, 22 Sep 2024 20:55:00 -0400 Subject: [PATCH 08/21] Warn users about running playwright install --- autoRSA.py | 2 +- fidelityAPI.py | 51 +++++++++++++++++++++++++++++++++----------------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/autoRSA.py b/autoRSA.py index 1f2a30b3..a33d3671 100755 --- a/autoRSA.py +++ b/autoRSA.py @@ -137,7 +137,7 @@ def fun_run(orderObj: stockOrder, command, botObj=None, loop=None): orderObj.set_logged_in( globals()[fun_name](botObj=botObj, loop=loop), broker ) - elif broker.lower() in ["chase", "vanguard", "fidelity"]: + elif broker.lower() in ["chase", "fidelity", "vanguard"]: fun_name = broker + "_run" # PLAYWRIGHT_BROKERS have to run all transactions with one function th = ThreadHandler( diff --git a/fidelityAPI.py b/fidelityAPI.py index 5d56b214..a2ffd939 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -5,18 +5,16 @@ # Adapted from Nelson Dane's Selenium based code and created with the help of playwright codegen import asyncio +import csv +import json import os import traceback -import json -import pyotp -import csv +import pyotp from dotenv import load_dotenv - from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError from playwright_stealth import StealthConfig, stealth_sync - from helperAPI import ( Brokerage, getOTPCodeDiscord, @@ -26,7 +24,7 @@ ) class FidelityAutomation: - def __init__(self, headless=True, title=None, profile_path='.') -> None: + def __init__(self, headless=True, title=None, profile_path='.', loop=None) -> None: # Setup the webdriver self.headless: bool = headless self.title: str = title @@ -35,7 +33,13 @@ def __init__(self, headless=True, title=None, profile_path='.') -> None: navigator_languages=False, navigator_user_agent=False, navigator_vendor=False) - self.getDriver() + try: + self.getDriver() + except Exception as e: + printAndDiscord( + "Error starting Fidelity driver! Please make sure your browser is installed by running: \"playwright install\"", + loop) + raise e def getDriver(self): ''' @@ -139,7 +143,7 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: # Prevent future OTP requirements self.page.locator("label").filter(has_text="Don't ask me again on this").check() assert self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked() - + # Log in with code self.page.get_by_role("button", name="Continue").click() @@ -163,7 +167,7 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: # Press the Text me button self.page.get_by_role("button", name="Text me the code").click() self.page.get_by_placeholder("XXXXXX").click() - + return (True, False) elif 'summary' not in self.page.url: @@ -398,21 +402,29 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr except PlaywrightTimeoutError as e: # Error must be present (or really slow page for some reason) # Try to report on error - error_message = 'Could not retrieve error message from popup' + error_message = '' filtered_error = '' try: - error_message = self.page.get_by_label("Error").locator("div").filter(has_text="critical").nth(2).text_content() + error_message = (self.page.get_by_label("Error").locator("div").filter(has_text="critical").nth(2).text_content(timeout=2000)) self.page.get_by_role("button", name="Close dialog").click() - except: + except Exception: pass + if error_message == '': + try: + error_message = self.page.wait_for_selector('.pvd-inline-alert__content font[color="red"]', timeout=2000).text_content() + self.page.get_by_role("button", name="Close dialog").click() + except Exception: + pass # Return with error and trim it down (it contains many spaces for some reason) - if error_message != None: + if error_message != '': for i, character in enumerate(error_message): - if i == 0 or (character == ' ' and error_message[i - 1] == ' ') or character == '\n' or character == '\t': + if ((character == ' ' and error_message[i - 1] == ' ') or character == '\n' or character == '\t'): continue filtered_error += character filtered_error = filtered_error.replace('critical', '').strip() error_message = filtered_error.replace('\n', '') + else: + error_message = 'Could not retrieve error message from popup' return (False, error_message) # If no error occurred, continue with checking and buy/sell @@ -442,6 +454,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr except Exception as e: return (False, e) + def fidelity_run(orderObj: stockOrder, command=None, botObj=None, loop=None, FIDELITY_EXTERNAL=None): ''' Entry point from main function. Gathers credentials and go through commands for @@ -487,6 +500,7 @@ def fidelity_run(orderObj: stockOrder, command=None, botObj=None, loop=None, FID fidelity_transaction(fidelityobj, name, orderObj, loop=loop) return None + def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None): ''' Log into fidelity. Creates a fidelity brokerage object and a FidelityAutomation object. @@ -512,7 +526,8 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None # Create a Fidelity browser object fidelity_browser = FidelityAutomation(headless=headless, title=name, - profile_path="./creds") + profile_path="./creds", + loop=loop) # Log into fidelity step_1, step_2 = fidelity_browser.login(account[0], account[1], account[2] if len(account) > 2 else None) @@ -526,7 +541,7 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None getOTPCodeDiscord(botObj, name, code_len=8, loop=loop), loop ).result() if sms_code is None: - raise Exception(f"{name} code not received in time...", loop) + raise Exception(f"{name} No SMS code found", loop) fidelity_browser.login_2FA(sms_code) elif not step_1: raise Exception(f"{name}: Login Failed. Got Error Page: Current URL: {fidelity_browser.page.url}") @@ -554,6 +569,7 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None print(traceback.format_exc()) return None + def fidelity_holdings(fidelity_o: Brokerage, name: str, loop=None): ''' Retrieves the holdings per account by reading from the previously downloaded positions csv file. @@ -602,6 +618,7 @@ def fidelity_holdings(fidelity_o: Brokerage, name: str, loop=None): # Close browser fidelity_browser.close_browser() + def fidelity_transaction(fidelity_o: Brokerage, name: str, orderObj: stockOrder, loop=None): ''' Using the Brokerage object, call FidelityAutomation.transaction() and process its' return @@ -644,4 +661,4 @@ def fidelity_transaction(fidelity_o: Brokerage, name: str, orderObj: stockOrder, printAndDiscord(f"{name} account xxxxx{account_number[-4:]}: {orderObj.get_action()} {orderObj.get_amount()} shares of {stock}", loop) # Close browser - fidelity_browser.close_browser() \ No newline at end of file + fidelity_browser.close_browser() From 24e651098d9c1f9eea8f2d07e35695ca6a921d01 Mon Sep 17 00:00:00 2001 From: matthew55 <78285385+matthew55@users.noreply.github.com> Date: Sun, 22 Sep 2024 22:15:25 -0400 Subject: [PATCH 09/21] Remove custom functions from Fidelity class --- README.md | 10 +++++++--- fidelityAPI.py | 15 ++++----------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 98bc87d9..fa7b2dbb 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,16 @@ You should see `(autorsa-venv)` in your terminal prompt now. You will need to ac ```bash pip install -r requirements.txt ``` -5. Add `DISCORD_TOKEN` and `DISCORD_CHANNEL` to your `.env` file. -6. Start the bot using the following command: +5. Install Playwright's dependencies: +```bash +playwright install +```` +6. Add `DISCORD_TOKEN` and `DISCORD_CHANNEL` to your `.env` file. +7. Start the bot using the following command: ```bash python autoRSA.py discord ``` -7. The bot should appear online in Discord (You can also do `!ping` to check). +8. The bot should appear online in Discord (You can also do `!ping` to check). ### CLI Tool Installation 💻 To run the CLI tool, follow these steps: diff --git a/fidelityAPI.py b/fidelityAPI.py index a2ffd939..e2b1d3ce 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -24,7 +24,7 @@ ) class FidelityAutomation: - def __init__(self, headless=True, title=None, profile_path='.', loop=None) -> None: + def __init__(self, headless=True, title=None, profile_path='.') -> None: # Setup the webdriver self.headless: bool = headless self.title: str = title @@ -33,13 +33,7 @@ def __init__(self, headless=True, title=None, profile_path='.', loop=None) -> No navigator_languages=False, navigator_user_agent=False, navigator_vendor=False) - try: - self.getDriver() - except Exception as e: - printAndDiscord( - "Error starting Fidelity driver! Please make sure your browser is installed by running: \"playwright install\"", - loop) - raise e + self.getDriver() def getDriver(self): ''' @@ -525,9 +519,8 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None account = account.split(":") # Create a Fidelity browser object fidelity_browser = FidelityAutomation(headless=headless, - title=name, - profile_path="./creds", - loop=loop) + title=name, + profile_path="./creds") # Log into fidelity step_1, step_2 = fidelity_browser.login(account[0], account[1], account[2] if len(account) > 2 else None) From 99c01ae0c67986973dd6b43c1b6499a8428eb746 Mon Sep 17 00:00:00 2001 From: Matthew <78285385+matthew55@users.noreply.github.com> Date: Mon, 23 Sep 2024 02:19:52 +0000 Subject: [PATCH 10/21] Remove unneeded character from README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Okay, this was due to a lack of caution on my part. The markdown rendered fine so I expected everything was fine and was lackluster with my analysis. Oops 💥 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fa7b2dbb..a8ca0726 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ pip install -r requirements.txt 5. Install Playwright's dependencies: ```bash playwright install -```` +``` 6. Add `DISCORD_TOKEN` and `DISCORD_CHANNEL` to your `.env` file. 7. Start the bot using the following command: ```bash From 83af30835aaa2a0f4b4d97fdbb7fdb23c1df8a43 Mon Sep 17 00:00:00 2001 From: kennyboy106 <tang.kenneth720@gmail.com> Date: Mon, 23 Sep 2024 21:14:25 -0700 Subject: [PATCH 11/21] Created new class function for summary of holdings --- fidelityAPI.py | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index e2b1d3ce..0fae7e6b 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -208,14 +208,18 @@ def login_2FA(self, code): def getAccountInfo(self): ''' Gets account numbers, account names, and account totals by downloading the csv of positions from fidelity. - The file path of the downloaded csv is saved to self.positions_csv and can be deleted later. Post Conditions: - self.positions_csv: The absolute file path to the downloaded csv file of positions for all accounts + self.account_dict is populated with holdings for each account Returns: - account_dict: dict: A dictionary using account numbers as keys. Each key holds a dict which has + account_dict: dict: A dictionary using account numbers as keys. Each key holds a dict which has: 'balance': float: Total account balance 'type': str: The account nickname or default name + 'stocks': list: A list of dictionaries for each stock found. The dict has: + 'ticker': str: The ticker of the stock held + 'quantity': str: The quantity of stocks with 'ticker' held + 'last_price': str: The last price of the stock with the $ sign removed + 'value': str: The total value of the position ''' # Go to positions page self.page.goto('https://digital.fidelity.com/ftgw/digital/portfolio/positions') @@ -287,6 +291,34 @@ def getAccountInfo(self): return self.account_dict + def summary_holdings(self) -> dict: + ''' + NOTE: The getAccountInfo function MUST be called before this, otherwise an empty dictionary will be returned + Returns a dictionary containing dictionaries for each stock owned across all accounts. + The keys of the outter dictionary are the tickers of the stocks owned. + Ex: unique_stocks['NVDA'] = {'quantity': 2.0, 'last_price': 120.23, 'value': 240.46} + 'quantity': float: The number of stocks held of 'ticker' + 'last_price': float: The last price of the stock + 'value': float: The total value of the stocks held + ''' + + unique_stocks = {} + + for account_number in self.account_dict: + for stock_dict in self.account_dict[account_number]['stocks']: + # Create a list of unique holdings + if stock_dict['ticker'] not in unique_stocks: + unique_stocks[stock_dict['ticker']] = {'quantity': float(stock_dict['quantity']), 'last_price': float(stock_dict['last_price']), 'value': float(stock_dict['value'])} + else: + unique_stocks[stock_dict['ticker']]['quantity'] += float(stock_dict['quantity']) + unique_stocks[stock_dict['ticker']]['value'] += float(stock_dict['value']) + + # Create a summary of holdings + summary = '' + for stock in unique_stocks: + summary += f"{stock}: {round(unique_stocks[stock]['quantity'], 2)} @ {unique_stocks[stock]['last_price']} = {round(unique_stocks[stock]['value'], 2)}\n" + return unique_stocks + def transaction(self, stock: str, quantity: float, action: str, account: str, dry: bool=True) -> bool: ''' Process an order (transaction) using the dedicated trading page. From 8d0b6556bc9e6be53298be384dfb1b98531289c1 Mon Sep 17 00:00:00 2001 From: kennyboy106 <tang.kenneth720@gmail.com> Date: Mon, 23 Sep 2024 21:39:45 -0700 Subject: [PATCH 12/21] Removed old code from fidelity_holdings --- fidelityAPI.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index 0fae7e6b..9e902276 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -612,7 +612,6 @@ def fidelity_holdings(fidelity_o: Brokerage, name: str, loop=None): # Get the browser back from the fidelity object fidelity_browser: FidelityAutomation = fidelity_o.get_logged_in_objects(name) - unique_stocks = {} account_dict = fidelity_browser.account_dict for account_number in account_dict: @@ -623,23 +622,10 @@ def fidelity_holdings(fidelity_o: Brokerage, name: str, loop=None): stock=d['ticker'], quantity=d['quantity'], price=d['last_price']) - # Create a list of unique holdings - if d['ticker'] not in unique_stocks: - unique_stocks[d['ticker']] = {'quantity': float(d['quantity']), 'last_price': d['last_price'], 'value': float(d['value'])} - else: - unique_stocks[d['ticker']]['quantity'] += float(d['quantity']) - unique_stocks[d['ticker']]['value'] += float(d['value']) # Print to console and to discord printHoldings(fidelity_o, loop) - # Create a summary of holdings - if len(account_dict.keys()) > 5: - summary = '' - for stock in unique_stocks: - summary += f"{stock}: {round(unique_stocks[stock]['quantity'], 2)} @ {unique_stocks[stock]['last_price']} = {round(unique_stocks[stock]['value'], 2)}\n" - printAndDiscord(f'Summary of holdings: \n{summary}', loop) - # Close browser fidelity_browser.close_browser() From 14c6173407fdd1f670b33d2d77ab99be505cd427 Mon Sep 17 00:00:00 2001 From: Nelson Dane <47427072+NelsonDane@users.noreply.github.com> Date: Thu, 26 Sep 2024 18:46:37 -0400 Subject: [PATCH 13/21] deepsource style fixes --- fidelityAPI.py | 63 +++++++++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index 9e902276..3729c89b 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -23,6 +23,7 @@ stockOrder, ) + class FidelityAutomation: def __init__(self, headless=True, title=None, profile_path='.') -> None: # Setup the webdriver @@ -93,7 +94,7 @@ def close_browser(self): def login(self, username: str, password: str, totp_secret: str=None) -> bool: ''' Logs into fidelity using the supplied username and password. - + Returns: True, True: If completely logged in, return (True, True) True, False: If 2FA is needed, this function will return (True, False) which signifies that the @@ -163,13 +164,9 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: self.page.get_by_placeholder("XXXXXX").click() return (True, False) - - elif 'summary' not in self.page.url: + + else: raise Exception("Cannot get to login page. Maybe other 2FA method present") - - # Some other case that isn't a log in. This shouldn't be reached under normal circumstances - return (False, False) - except PlaywrightTimeoutError: print("Timeout waiting for login page to load or navigate.") return (False, False) @@ -181,14 +178,14 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: def login_2FA(self, code): ''' Completes the 2FA portion of the login using a phone text code. - + Returns: True: bool: If login succeeded, return true. False: bool: If login failed, return false. ''' try: self.page.get_by_placeholder("XXXXXX").fill(code) - + # Prevent future OTP requirements self.page.locator("label").filter(has_text="Don't ask me again on this").check() assert self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked() @@ -196,7 +193,7 @@ def login_2FA(self, code): self.page.wait_for_url('https://digital.fidelity.com/ftgw/digital/portfolio/summary', timeout=5000) return True - + except PlaywrightTimeoutError: print("Timeout waiting for login page to load or navigate.") return False @@ -204,7 +201,7 @@ def login_2FA(self, code): print(f"An error occurred: {str(e)}") traceback.print_exc() return False - + def getAccountInfo(self): ''' Gets account numbers, account names, and account totals by downloading the csv of positions from fidelity. @@ -223,7 +220,7 @@ def getAccountInfo(self): ''' # Go to positions page self.page.goto('https://digital.fidelity.com/ftgw/digital/portfolio/positions') - + # Download the positions as a csv with self.page.expect_download() as download_info: self.page.get_by_label("Download Positions").click() @@ -241,7 +238,7 @@ def getAccountInfo(self): intersection_set = set(reader.fieldnames).intersection(set(required_elements)) if len(intersection_set) != len(required_elements): raise Exception('Not enough elements in fidelity positions csv') - + self.account_dict = {} for row in reader: # Last couple of rows have some disclaimers, filter those out @@ -255,7 +252,7 @@ def getAccountInfo(self): quantity = row['Quantity'] # Get ticker ticker = str(row['Symbol']) - + # Don't include this if present if 'Pending' in ticker: continue @@ -270,7 +267,7 @@ def getAccountInfo(self): # If the quantity is missing, just use 1 if len(quantity) == 0: quantity = 1 - + # If the account number isn't populated yet, add it if row['Account Number'] not in self.account_dict: # Add retrieved info. @@ -284,7 +281,7 @@ def getAccountInfo(self): else: self.account_dict[row['Account Number']]['stocks'].append({'ticker': ticker, 'quantity': quantity, 'last_price': last_price, 'value': val}) self.account_dict[row['Account Number']]['balance'] += float(val) - + # Close the file csv_file.close() os.remove(self.positions_csv) @@ -324,7 +321,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr Process an order (transaction) using the dedicated trading page. NOTE: If you use this function repeatedly but change the stock between ANY call, RELOAD the page before calling this - + For buying: If the price of the security is below $1, it will choose limit order and go off of the last price + a little For selling: @@ -336,7 +333,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr action: str: This must be 'buy' or 'sell'. It can be in any case state (i.e. 'bUY' is still valid) account: str: The account number to trade under. dry: bool: True for dry (test) run, False for real run. - + Returns: (Success: bool, Error_message: str) If the order was successfully placed or tested (for dry runs) then True is returned and Error_message will be None. Otherwise, False will be returned and Error_message will not be None @@ -348,7 +345,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr # Click on the drop down self.page.query_selector("#dest-acct-dropdown").click() - + if not self.page.get_by_role("option").filter(has_text=account.upper()).is_visible(): # Reload the page and hit the drop down again # This is to prevent a rare case where the drop down is empty @@ -406,7 +403,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr else: difference_price = 0.01 if float(last_price) > 0.1 else 0.0001 wanted_price = round(float(last_price) - difference_price, precision) - + # Click on the limit default option when in extended hours self.page.query_selector("#dest-dropdownlist-button-ordertype > span:nth-child(1)").click() self.page.get_by_role("option", name="Limit", exact=True).click() @@ -425,7 +422,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr # If error occurred try: self.page.get_by_role("button", name="Place order clicking this").wait_for(timeout=4000, state='visible') - except PlaywrightTimeoutError as e: + except PlaywrightTimeoutError: # Error must be present (or really slow page for some reason) # Try to report on error error_message = '' @@ -452,7 +449,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr else: error_message = 'Could not retrieve error message from popup' return (False, error_message) - + # If no error occurred, continue with checking and buy/sell try: assert self.page.locator("preview").filter(has_text=account.upper()).is_visible() @@ -461,7 +458,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr assert self.page.get_by_text(f"Quantity{quantity}").is_visible() except AssertionError: return (False, 'Order preview is not what is expected') - + # If its a real run if not dry: self.page.get_by_role("button", name="Place order clicking this").click() @@ -472,11 +469,11 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr return (True, None) except PlaywrightTimeoutError as e: # Order didn't go through for some reason, go to the next and say error - return (False, f'Order failed to complete') + return (False, 'Order failed to complete') # If its a dry run, report back success return (True, None) except PlaywrightTimeoutError as e: - return (False, f'Driver timed out. Order not complete') + return (False, 'Driver timed out. Order not complete') except Exception as e: return (False, e) @@ -550,9 +547,7 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None # Split the login into into separate items account = account.split(":") # Create a Fidelity browser object - fidelity_browser = FidelityAutomation(headless=headless, - title=name, - profile_path="./creds") + fidelity_browser = FidelityAutomation(headless=headless, title=name, profile_path="./creds") # Log into fidelity step_1, step_2 = fidelity_browser.login(account[0], account[1], account[2] if len(account) > 2 else None) @@ -570,13 +565,13 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None fidelity_browser.login_2FA(sms_code) elif not step_1: raise Exception(f"{name}: Login Failed. Got Error Page: Current URL: {fidelity_browser.page.url}") - + # By this point, we should be logged in so save the driver fidelity_obj.set_logged_in_object(name, fidelity_browser) # Getting account numbers, names, and balances account_dict = fidelity_browser.getAccountInfo() - + if account_dict is None: raise Exception(f'{name}: Error getting account info') # Set info into fidelity brokerage object @@ -588,7 +583,7 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None ) print(f"Logged in to {name}!") return fidelity_obj - + except Exception as e: print(f"Error logging in to Fidelity: {e}") print(traceback.format_exc()) @@ -609,7 +604,7 @@ def fidelity_holdings(fidelity_o: Brokerage, name: str, loop=None): Returns: None ''' - + # Get the browser back from the fidelity object fidelity_browser: FidelityAutomation = fidelity_o.get_logged_in_objects(name) account_dict = fidelity_browser.account_dict @@ -640,7 +635,7 @@ def fidelity_transaction(fidelity_o: Brokerage, name: str, orderObj: stockOrder, name: str: The name of this brokerage object (ex: Fidelity 1) orderObj: stockOrder: The stock object used for storing stocks to buy or sell loop: AbstractEventLoop: The event loop to be used - + Returns: None ''' @@ -670,6 +665,6 @@ def fidelity_transaction(fidelity_o: Brokerage, name: str, orderObj: stockOrder, # Print real run confirmation if real run elif success and not orderObj.get_dry(): printAndDiscord(f"{name} account xxxxx{account_number[-4:]}: {orderObj.get_action()} {orderObj.get_amount()} shares of {stock}", loop) - + # Close browser fidelity_browser.close_browser() From 9e7fdcf3f1de262448e8ffc8fdbeb9764c833fef Mon Sep 17 00:00:00 2001 From: Kenny <tang.kenneth720@gmail.com> Date: Thu, 26 Sep 2024 16:18:02 -0700 Subject: [PATCH 14/21] Fixed deepsource errors --- .gitignore | 1 + fidelityAPI.py | 163 ++++++++++++++++++++++++------------------------- 2 files changed, 82 insertions(+), 82 deletions(-) diff --git a/.gitignore b/.gitignore index abab5024..7b6ea0af 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ test* *venv/ .vscode/ *.zip +*workspace diff --git a/fidelityAPI.py b/fidelityAPI.py index 9e902276..cfa44031 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -29,6 +29,7 @@ def __init__(self, headless=True, title=None, profile_path='.') -> None: self.headless: bool = headless self.title: str = title self.profile_path: str = profile_path + self.account_dict: dict = None self.stealth_config = StealthConfig( navigator_languages=False, navigator_user_agent=False, @@ -93,13 +94,12 @@ def close_browser(self): def login(self, username: str, password: str, totp_secret: str=None) -> bool: ''' Logs into fidelity using the supplied username and password. - + Returns: True, True: If completely logged in, return (True, True) - True, False: If 2FA is needed, this function will return (True, False) which signifies that the + True, False: If 2FA is needed, this function will return (True, False) which signifies that the initial login attempt was successful but further action is needed to finish logging in. False, False: Initial login attempt failed. - ''' try: # Go to the login page @@ -128,7 +128,7 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: # If TOTP secret is provided, we are will use the TOTP key. See if authenticator code is present if totp_secret is not None and self.page.get_by_role("heading", name="Enter the code from your").is_visible(): - # Get authenticator code + # Get authenticator code code = pyotp.TOTP(totp_secret).now() # Enter the code self.page.get_by_placeholder("XXXXXX").click() @@ -136,7 +136,8 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: # Prevent future OTP requirements self.page.locator("label").filter(has_text="Don't ask me again on this").check() - assert self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked() + if not self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked(): + raise Exception("Cannot check 'Don't ask me again on this device' box") # Log in with code self.page.get_by_role("button", name="Continue").click() @@ -153,7 +154,8 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: # If the app push notification page is present if self.page.get_by_role("link", name="Try another way").is_visible(): self.page.locator("label").filter(has_text="Don't ask me again on this").check() - assert self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked() + if not self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked(): + raise Exception("Cannot check 'Don't ask me again on this device' box") # Click on alternate verification method to get OTP via text self.page.get_by_role("link", name="Try another way").click() @@ -175,23 +177,24 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: return (False, False) except Exception as e: print(f"An error occurred: {str(e)}") - traceback.print_exc() + traceback.print_exc() return (False, False) def login_2FA(self, code): ''' Completes the 2FA portion of the login using a phone text code. - + Returns: True: bool: If login succeeded, return true. False: bool: If login failed, return false. ''' try: self.page.get_by_placeholder("XXXXXX").fill(code) - + # Prevent future OTP requirements self.page.locator("label").filter(has_text="Don't ask me again on this").check() - assert self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked() + if not self.page.locator("label").filter(has_text="Don't ask me again on this").is_checked(): + raise Exception("Cannot check 'Don't ask me again on this device' box") self.page.get_by_role("button", name="Submit").click() self.page.wait_for_url('https://digital.fidelity.com/ftgw/digital/portfolio/summary', timeout=5000) @@ -202,7 +205,7 @@ def login_2FA(self, code): return False except Exception as e: print(f"An error occurred: {str(e)}") - traceback.print_exc() + traceback.print_exc() return False def getAccountInfo(self): @@ -229,11 +232,11 @@ def getAccountInfo(self): self.page.get_by_label("Download Positions").click() download = download_info.value cur = os.getcwd() - self.positions_csv = os.path.join(cur, download.suggested_filename) + positions_csv = os.path.join(cur, download.suggested_filename) # Create a copy to work on with the proper file name known - download.save_as(self.positions_csv) + download.save_as(positions_csv) - csv_file = open(self.positions_csv, newline='', encoding='utf-8-sig') + csv_file = open(positions_csv, newline='', encoding='utf-8-sig') reader = csv.DictReader(csv_file) # Ensure all fields we want are present @@ -244,50 +247,50 @@ def getAccountInfo(self): self.account_dict = {} for row in reader: - # Last couple of rows have some disclaimers, filter those out - if row['Account Number'] is not None and 'and' in str(row['Account Number']): - break - # Get the value and remove '$' from it - val = str(row['Current Value']).replace('$', '') - # Get the last price - last_price = str(row['Last Price']).replace('$', '') - # Get quantity - quantity = row['Quantity'] - # Get ticker - ticker = str(row['Symbol']) - - # Don't include this if present - if 'Pending' in ticker: - continue - # If the value isn't present, move to next row - if len(val) == 0: - continue - elif val.lower() == 'n/a': - val = 0 - # If the last price isn't available, just use the current value - if len(last_price) == 0: - last_price = val - # If the quantity is missing, just use 1 - if len(quantity) == 0: - quantity = 1 - - # If the account number isn't populated yet, add it - if row['Account Number'] not in self.account_dict: - # Add retrieved info. - # Yeah I know is kinda messy and hard to think about but it works - # Just need a way to store all stocks with the account number - # 'stocks' is a list of dictionaries. Each ticker gets its own index and is described by a dictionary - self.account_dict[row['Account Number']] = {'balance': float(val), 'type': row['Account Name'], - 'stocks': [{'ticker': ticker, 'quantity': quantity, 'last_price': last_price, 'value': val}] - } - # If it is present, add to it - else: - self.account_dict[row['Account Number']]['stocks'].append({'ticker': ticker, 'quantity': quantity, 'last_price': last_price, 'value': val}) - self.account_dict[row['Account Number']]['balance'] += float(val) + # Last couple of rows have some disclaimers, filter those out + if row['Account Number'] is not None and 'and' in str(row['Account Number']): + break + # Get the value and remove '$' from it + val = str(row['Current Value']).replace('$', '') + # Get the last price + last_price = str(row['Last Price']).replace('$', '') + # Get quantity + quantity = row['Quantity'] + # Get ticker + ticker = str(row['Symbol']) + + # Don't include this if present + if 'Pending' in ticker: + continue + # If the value isn't present, move to next row + if len(val) == 0: + continue + elif val.lower() == 'n/a': + val = 0 + # If the last price isn't available, just use the current value + if len(last_price) == 0: + last_price = val + # If the quantity is missing, just use 1 + if len(quantity) == 0: + quantity = 1 + + # If the account number isn't populated yet, add it + if row['Account Number'] not in self.account_dict: + # Add retrieved info. + # Yeah I know is kinda messy and hard to think about but it works + # Just need a way to store all stocks with the account number + # 'stocks' is a list of dictionaries. Each ticker gets its own index and is described by a dictionary + self.account_dict[row['Account Number']] = {'balance': float(val), 'type': row['Account Name'], + 'stocks': [{'ticker': ticker, 'quantity': quantity, 'last_price': last_price, 'value': val}] + } + # If it is present, add to it + else: + self.account_dict[row['Account Number']]['stocks'].append({'ticker': ticker, 'quantity': quantity, 'last_price': last_price, 'value': val}) + self.account_dict[row['Account Number']]['balance'] += float(val) # Close the file csv_file.close() - os.remove(self.positions_csv) + os.remove(positions_csv) return self.account_dict @@ -295,7 +298,7 @@ def summary_holdings(self) -> dict: ''' NOTE: The getAccountInfo function MUST be called before this, otherwise an empty dictionary will be returned Returns a dictionary containing dictionaries for each stock owned across all accounts. - The keys of the outter dictionary are the tickers of the stocks owned. + The keys of the outer dictionary are the tickers of the stocks owned. Ex: unique_stocks['NVDA'] = {'quantity': 2.0, 'last_price': 120.23, 'value': 240.46} 'quantity': float: The number of stocks held of 'ticker' 'last_price': float: The last price of the stock @@ -315,8 +318,8 @@ def summary_holdings(self) -> dict: # Create a summary of holdings summary = '' - for stock in unique_stocks: - summary += f"{stock}: {round(unique_stocks[stock]['quantity'], 2)} @ {unique_stocks[stock]['last_price']} = {round(unique_stocks[stock]['value'], 2)}\n" + for stock, st_dict in unique_stocks.items(): + summary += f"{stock}: {round(st_dict['quantity'], 2)} @ {st_dict['last_price']} = {round(st_dict['value'], 2)}\n" return unique_stocks def transaction(self, stock: str, quantity: float, action: str, account: str, dry: bool=True) -> bool: @@ -453,13 +456,11 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr error_message = 'Could not retrieve error message from popup' return (False, error_message) - # If no error occurred, continue with checking and buy/sell - try: - assert self.page.locator("preview").filter(has_text=account.upper()).is_visible() - assert self.page.get_by_text(f"Symbol{stock.upper()}", exact=True).is_visible() - assert self.page.get_by_text(f"Action{action.lower().title()}").is_visible() - assert self.page.get_by_text(f"Quantity{quantity}").is_visible() - except AssertionError: + # If no error occurred, continue with checking the order preview + if (not self.page.locator("preview").filter(has_text=account.upper()).is_visible() or + not self.page.get_by_text(f"Symbol{stock.upper()}", exact=True).is_visible() or + not self.page.get_by_text(f"Action{action.lower().title()}").is_visible() or + not self.page.get_by_text(f"Quantity{quantity}").is_visible()): return (False, 'Order preview is not what is expected') # If its a real run @@ -470,7 +471,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr self.page.get_by_text("Order received").wait_for(timeout=5000, state='visible') # If no error, return with success return (True, None) - except PlaywrightTimeoutError as e: + except PlaywrightTimeoutError: # Order didn't go through for some reason, go to the next and say error return (False, f'Order failed to complete') # If its a dry run, report back success @@ -483,7 +484,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr def fidelity_run(orderObj: stockOrder, command=None, botObj=None, loop=None, FIDELITY_EXTERNAL=None): ''' - Entry point from main function. Gathers credentials and go through commands for + Entry point from main function. Gathers credentials and go through commands for each set of credentials found in the FIDELITY env variable Returns: @@ -531,8 +532,8 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None ''' Log into fidelity. Creates a fidelity brokerage object and a FidelityAutomation object. The FidelityAutomation object is stored within the brokerage object and some account information - is gathered. - + is gathered. + Post conditions: Logs into fidelity using the supplied credentials Returns: @@ -581,11 +582,9 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None raise Exception(f'{name}: Error getting account info') # Set info into fidelity brokerage object for acct in account_dict: - fidelity_obj.set_account_number(name, acct) - fidelity_obj.set_account_type(name, acct, account_dict[acct]["type"]) - fidelity_obj.set_account_totals( - name, acct, account_dict[acct]["balance"] - ) + fidelity_obj.set_account_number(name, acct) + fidelity_obj.set_account_type(name, acct, account_dict[acct]["type"]) + fidelity_obj.set_account_totals(name, acct, account_dict[acct]["balance"]) print(f"Logged in to {name}!") return fidelity_obj @@ -609,7 +608,7 @@ def fidelity_holdings(fidelity_o: Brokerage, name: str, loop=None): Returns: None ''' - + # Get the browser back from the fidelity object fidelity_browser: FidelityAutomation = fidelity_o.get_logged_in_objects(name) account_dict = fidelity_browser.account_dict @@ -617,10 +616,10 @@ def fidelity_holdings(fidelity_o: Brokerage, name: str, loop=None): for d in account_dict[account_number]['stocks']: # Append the ticker to the appropriate account - fidelity_o.set_holdings(parent_name=name, - account_name=account_number, - stock=d['ticker'], - quantity=d['quantity'], + fidelity_o.set_holdings(parent_name=name, + account_name=account_number, + stock=d['ticker'], + quantity=d['quantity'], price=d['last_price']) # Print to console and to discord @@ -658,11 +657,11 @@ def fidelity_transaction(fidelity_o: Brokerage, name: str, orderObj: stockOrder, fidelity_browser.page.reload() for account_number in fidelity_o.get_account_numbers(name): # Go trade for all accounts for that stock - success, error_message = fidelity_browser.transaction(stock, orderObj.get_amount(), orderObj.get_action(), + success, error_message = fidelity_browser.transaction(stock, orderObj.get_amount(), orderObj.get_action(), account_number, orderObj.get_dry()) # Report error if occurred if not success: - printAndDiscord(f"{name} account xxxxx{account_number[-4:]}: {orderObj.get_action()} {orderObj.get_amount()} {error_message}", + printAndDiscord(f"{name} account xxxxx{account_number[-4:]}: {orderObj.get_action()} {orderObj.get_amount()} {error_message}", loop) # Print test run confirmation if test run elif success and orderObj.get_dry(): @@ -670,6 +669,6 @@ def fidelity_transaction(fidelity_o: Brokerage, name: str, orderObj: stockOrder, # Print real run confirmation if real run elif success and not orderObj.get_dry(): printAndDiscord(f"{name} account xxxxx{account_number[-4:]}: {orderObj.get_action()} {orderObj.get_amount()} shares of {stock}", loop) - + # Close browser fidelity_browser.close_browser() From ba2c8e1ace1771c7e5b435c0178919483b24fc7f Mon Sep 17 00:00:00 2001 From: Kenny <tang.kenneth720@gmail.com> Date: Thu, 26 Sep 2024 16:25:30 -0700 Subject: [PATCH 15/21] Remove double declare --- fidelityAPI.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index cfa44031..607f7893 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -29,7 +29,7 @@ def __init__(self, headless=True, title=None, profile_path='.') -> None: self.headless: bool = headless self.title: str = title self.profile_path: str = profile_path - self.account_dict: dict = None + self.account_dict: dict = {} self.stealth_config = StealthConfig( navigator_languages=False, navigator_user_agent=False, @@ -244,8 +244,7 @@ def getAccountInfo(self): intersection_set = set(reader.fieldnames).intersection(set(required_elements)) if len(intersection_set) != len(required_elements): raise Exception('Not enough elements in fidelity positions csv') - - self.account_dict = {} + for row in reader: # Last couple of rows have some disclaimers, filter those out if row['Account Number'] is not None and 'and' in str(row['Account Number']): From dc9ba6c7af744d5433c2abef408e625c0dcf815c Mon Sep 17 00:00:00 2001 From: Kenny <tang.kenneth720@gmail.com> Date: Thu, 26 Sep 2024 16:40:11 -0700 Subject: [PATCH 16/21] More fixes --- fidelityAPI.py | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index ad323ebc..c13d622e 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -23,7 +23,6 @@ stockOrder, ) - class FidelityAutomation: def __init__(self, headless=True, title=None, profile_path='.') -> None: # Setup the webdriver @@ -167,8 +166,12 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: return (True, False) - else: + if 'summary' not in self.page.url: raise Exception("Cannot get to login page. Maybe other 2FA method present") + + # Some other case that isn't a log in. This shouldn't be reached under normal circumstances + return (False, False) + except PlaywrightTimeoutError: print("Timeout waiting for login page to load or navigate.") return (False, False) @@ -254,14 +257,14 @@ def getAccountInfo(self): quantity = row['Quantity'] # Get ticker ticker = str(row['Symbol']) - + # Don't include this if present if 'Pending' in ticker: continue # If the value isn't present, move to next row if len(val) == 0: continue - elif val.lower() == 'n/a': + if val.lower() == 'n/a': val = 0 # If the last price isn't available, just use the current value if len(last_price) == 0: @@ -269,7 +272,7 @@ def getAccountInfo(self): # If the quantity is missing, just use 1 if len(quantity) == 0: quantity = 1 - + # If the account number isn't populated yet, add it if row['Account Number'] not in self.account_dict: # Add retrieved info. @@ -282,7 +285,7 @@ def getAccountInfo(self): # If it is present, add to it else: self.account_dict[row['Account Number']]['stocks'].append({'ticker': ticker, 'quantity': quantity, 'last_price': last_price, 'value': val}) - self.account_dict[row['Account Number']]['balance'] += float(val) + self.account_dict[row['Account Number']]['balance'] += float(val) # Close the file csv_file.close() @@ -321,7 +324,7 @@ def summary_holdings(self) -> dict: def transaction(self, stock: str, quantity: float, action: str, account: str, dry: bool=True) -> bool: ''' Process an order (transaction) using the dedicated trading page. - NOTE: If you use this function repeatedly but change the stock between ANY call, + NOTE: If you use this function repeatedly but change the stock between ANY call, RELOAD the page before calling this For buying: @@ -451,13 +454,12 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr else: error_message = 'Could not retrieve error message from popup' return (False, error_message) - + # If no error occurred, continue with checking the order preview if (not self.page.locator("preview").filter(has_text=account.upper()).is_visible() or - not self.page.get_by_text(f"Symbol{stock.upper()}", exact=True).is_visible() or - not self.page.get_by_text(f"Action{action.lower().title()}").is_visible() or - not self.page.get_by_text(f"Quantity{quantity}").is_visible()): - + not self.page.get_by_text(f"Symbol{stock.upper()}", exact=True).is_visible() or + not self.page.get_by_text(f"Action{action.lower().title()}").is_visible() or + not self.page.get_by_text(f"Quantity{quantity}").is_visible()): return (False, 'Order preview is not what is expected') # If its a real run @@ -470,11 +472,11 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr return (True, None) except PlaywrightTimeoutError: # Order didn't go through for some reason, go to the next and say error - return (False, 'Order failed to complete') + return (False, f'Order failed to complete') # If its a dry run, report back success return (True, None) - except PlaywrightTimeoutError as e: - return (False, 'Driver timed out. Order not complete') + except PlaywrightTimeoutError: + return (False, f'Driver timed out. Order not complete') except Exception as e: return (False, e) @@ -548,7 +550,9 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None # Split the login into into separate items account = account.split(":") # Create a Fidelity browser object - fidelity_browser = FidelityAutomation(headless=headless, title=name, profile_path="./creds") + fidelity_browser = FidelityAutomation(headless=headless, + title=name, + profile_path="./creds") # Log into fidelity step_1, step_2 = fidelity_browser.login(account[0], account[1], account[2] if len(account) > 2 else None) @@ -566,13 +570,13 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None fidelity_browser.login_2FA(sms_code) elif not step_1: raise Exception(f"{name}: Login Failed. Got Error Page: Current URL: {fidelity_browser.page.url}") - + # By this point, we should be logged in so save the driver fidelity_obj.set_logged_in_object(name, fidelity_browser) # Getting account numbers, names, and balances account_dict = fidelity_browser.getAccountInfo() - + if account_dict is None: raise Exception(f'{name}: Error getting account info') # Set info into fidelity brokerage object @@ -582,7 +586,7 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None fidelity_obj.set_account_totals(name, acct, account_dict[acct]["balance"]) print(f"Logged in to {name}!") return fidelity_obj - + except Exception as e: print(f"Error logging in to Fidelity: {e}") print(traceback.format_exc()) @@ -634,7 +638,7 @@ def fidelity_transaction(fidelity_o: Brokerage, name: str, orderObj: stockOrder, name: str: The name of this brokerage object (ex: Fidelity 1) orderObj: stockOrder: The stock object used for storing stocks to buy or sell loop: AbstractEventLoop: The event loop to be used - + Returns: None ''' From 29448014d66793b4e155eb075fb05d0efbe7afa2 Mon Sep 17 00:00:00 2001 From: Kenny <tang.kenneth720@gmail.com> Date: Thu, 26 Sep 2024 16:50:04 -0700 Subject: [PATCH 17/21] Fix rare case where login url isn't what's expected --- fidelityAPI.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index c13d622e..4cffdc5e 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -166,11 +166,8 @@ def login(self, username: str, password: str, totp_secret: str=None) -> bool: return (True, False) - if 'summary' not in self.page.url: - raise Exception("Cannot get to login page. Maybe other 2FA method present") - - # Some other case that isn't a log in. This shouldn't be reached under normal circumstances - return (False, False) + # Can't get to summary and we aren't on the login page, idk what's going on + raise Exception("Cannot get to login page. Maybe other 2FA method present") except PlaywrightTimeoutError: print("Timeout waiting for login page to load or navigate.") From 2af8b831b1f4257e4159bb822556a7d048573503 Mon Sep 17 00:00:00 2001 From: Kenny <tang.kenneth720@gmail.com> Date: Thu, 26 Sep 2024 16:53:01 -0700 Subject: [PATCH 18/21] Removed extra spaces --- fidelityAPI.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index 4cffdc5e..002a2543 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -469,11 +469,11 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr return (True, None) except PlaywrightTimeoutError: # Order didn't go through for some reason, go to the next and say error - return (False, f'Order failed to complete') + return (False, 'Order failed to complete') # If its a dry run, report back success return (True, None) except PlaywrightTimeoutError: - return (False, f'Driver timed out. Order not complete') + return (False, 'Driver timed out. Order not complete') except Exception as e: return (False, e) @@ -547,9 +547,7 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None # Split the login into into separate items account = account.split(":") # Create a Fidelity browser object - fidelity_browser = FidelityAutomation(headless=headless, - title=name, - profile_path="./creds") + fidelity_browser = FidelityAutomation(headless=headless, title=name, profile_path="./creds") # Log into fidelity step_1, step_2 = fidelity_browser.login(account[0], account[1], account[2] if len(account) > 2 else None) @@ -567,13 +565,13 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None fidelity_browser.login_2FA(sms_code) elif not step_1: raise Exception(f"{name}: Login Failed. Got Error Page: Current URL: {fidelity_browser.page.url}") - + # By this point, we should be logged in so save the driver fidelity_obj.set_logged_in_object(name, fidelity_browser) # Getting account numbers, names, and balances account_dict = fidelity_browser.getAccountInfo() - + if account_dict is None: raise Exception(f'{name}: Error getting account info') # Set info into fidelity brokerage object @@ -583,7 +581,7 @@ def fidelity_init(account: str, name: str, headless=True, botObj=None, loop=None fidelity_obj.set_account_totals(name, acct, account_dict[acct]["balance"]) print(f"Logged in to {name}!") return fidelity_obj - + except Exception as e: print(f"Error logging in to Fidelity: {e}") print(traceback.format_exc()) @@ -635,7 +633,7 @@ def fidelity_transaction(fidelity_o: Brokerage, name: str, orderObj: stockOrder, name: str: The name of this brokerage object (ex: Fidelity 1) orderObj: stockOrder: The stock object used for storing stocks to buy or sell loop: AbstractEventLoop: The event loop to be used - + Returns: None ''' From 2aff78b44d96d7b7ba7ee80318b36d42c35b7c0f Mon Sep 17 00:00:00 2001 From: Kenny <tang.kenneth720@gmail.com> Date: Thu, 26 Sep 2024 16:57:15 -0700 Subject: [PATCH 19/21] Last error? Please? --- fidelityAPI.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/fidelityAPI.py b/fidelityAPI.py index 002a2543..30c0343e 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -24,6 +24,10 @@ ) class FidelityAutomation: + ''' + A class to manage and control a playwright webdriver with Fidelity + ''' + def __init__(self, headless=True, title=None, profile_path='.') -> None: # Setup the webdriver self.headless: bool = headless @@ -457,6 +461,7 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr not self.page.get_by_text(f"Symbol{stock.upper()}", exact=True).is_visible() or not self.page.get_by_text(f"Action{action.lower().title()}").is_visible() or not self.page.get_by_text(f"Quantity{quantity}").is_visible()): + return (False, 'Order preview is not what is expected') # If its a real run From 775aecd72fb26cdf6f0f75756758e6c8f5355344 Mon Sep 17 00:00:00 2001 From: Nelson Dane <47427072+NelsonDane@users.noreply.github.com> Date: Thu, 26 Sep 2024 21:10:55 -0400 Subject: [PATCH 20/21] rest of deepsource fixes --- fidelityAPI.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/fidelityAPI.py b/fidelityAPI.py index 30c0343e..f699ecc0 100755 --- a/fidelityAPI.py +++ b/fidelityAPI.py @@ -23,6 +23,7 @@ stockOrder, ) + class FidelityAutomation: ''' A class to manage and control a playwright webdriver with Fidelity @@ -457,11 +458,12 @@ def transaction(self, stock: str, quantity: float, action: str, account: str, dr return (False, error_message) # If no error occurred, continue with checking the order preview - if (not self.page.locator("preview").filter(has_text=account.upper()).is_visible() or + if ( + not self.page.locator("preview").filter(has_text=account.upper()).is_visible() or not self.page.get_by_text(f"Symbol{stock.upper()}", exact=True).is_visible() or not self.page.get_by_text(f"Action{action.lower().title()}").is_visible() or - not self.page.get_by_text(f"Quantity{quantity}").is_visible()): - + not self.page.get_by_text(f"Quantity{quantity}").is_visible() + ): return (False, 'Order preview is not what is expected') # If its a real run From 72274973d43e93685a2c0b010abb0ab24b1e3021 Mon Sep 17 00:00:00 2001 From: Nelson Dane <47427072+NelsonDane@users.noreply.github.com> Date: Thu, 26 Sep 2024 21:15:06 -0400 Subject: [PATCH 21/21] alphabatize gitignore --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 7b6ea0af..3fd70bea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ *.csv -.env did.bin .DS_Store +.env *.json *.pkl *.png @@ -10,5 +10,5 @@ src/ test* *venv/ .vscode/ -*.zip *workspace +*.zip