diff --git a/.gitignore b/.gitignore index 304d7d4..9d45d97 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ *$py.class testme.py *.pkl +_*.py # C extensions *.so diff --git a/README.md b/README.md index 613e8eb..1cf0f00 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,10 @@ In order to use Fractional shares you must accept the agreement on the website b ## Contribution -I am new to coding and new to open-source. I would love any help and suggestions! +Please feel free to contribute to this project. If you find any bugs, please open an issue. + +## Disclaimer +I am not a financial advisor and not affiliated with Firstrade in any way. Use this tool at your own risk. I am not responsible for any losses or damages you may incur by using this project. This tool is provided as-is with no warranty. ## Setup @@ -24,33 +27,41 @@ pip install firstrade ## Quikstart -`Checkout test.py for sample code.` - -This code will: +The code in `test.py` will: - Login and print account info. - Get a quote for 'INTC' and print out the information -- Place a market order for 'INTC' on the first account in the `account_numbers` list +- Place a dry run market order for 'INTC' on the first account in the `account_numbers` list - Print out the order confirmation - -`Checkout test.py for sample code.` - +- Contains a cancel order example +- Get an option Dates, Quotes, and Greeks +- Place a dry run option order --- ## Implemented Features -- [x] Login +- [x] Login (With all 2FA methods now supported!) - [x] Get Quotes - [x] Get Account Data - [x] Place Orders and Receive order confirmation - [x] Get Currently Held Positions - [x] Fractional Trading support (thanks to @jiak94) - [x] Check on placed order status. (thanks to @Cfomodz) +- [x] Cancel placed orders +- [x] Options (Orders, Quotes, Greeks) +- [x] Order History ## TO DO -- [ ] Cancel placed orders -- [ ] Options +- [ ] Test options fully - [ ] Give me some Ideas! +## Options + +### I am very new to options trading and have not fully tested this feature. + +Please: +- USE THIS FEATURE LIKE IT IS A ALPHA/BETA +- PUT IN A GITHUB ISSUE IF YOU FIND ANY PROBLEMS + ## If you would like to support me, you can do so here: -[![GitHub Sponsors](https://img.shields.io/github/sponsors/maxxrk?style=social)](https://github.com/sponsors/maxxrk) \ No newline at end of file +[![GitHub Sponsors](https://img.shields.io/github/sponsors/maxxrk?style=social)](https://github.com/sponsors/maxxrk) \ No newline at end of file diff --git a/firstrade/account.py b/firstrade/account.py index bf730bd..fd9bb96 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -1,17 +1,67 @@ +import json import os import pickle -import re +import pyotp import requests -from bs4 import BeautifulSoup from firstrade import urls +from firstrade.exceptions import ( + AccountResponseError, + LoginRequestError, + LoginResponseError, +) class FTSession: - """Class creating a session for Firstrade.""" + """ + Class creating a session for Firstrade. - def __init__(self, username, password, pin, profile_path=None): + This class handles the creation and management of a session for logging into the Firstrade platform. + It supports multi-factor authentication (MFA) and can save session cookies for persistent logins. + + Attributes: + username (str): Firstrade login username. + password (str): Firstrade login password. + pin (str): Firstrade login pin. + email (str, optional): Firstrade MFA email. + phone (str, optional): Firstrade MFA phone number. + mfa_secret (str, optional): Secret key for generating MFA codes. + profile_path (str, optional): The path where the user wants to save the cookie pkl file. + t_token (str, optional): Token used for MFA. + otp_options (dict, optional): Options for OTP (One-Time Password) if MFA is enabled. + login_json (dict, optional): JSON response from the login request. + session (requests.Session): The requests session object used for making HTTP requests. + + Methods: + __init__(username, password, pin=None, email=None, phone=None, mfa_secret=None, profile_path=None): + Initializes a new instance of the FTSession class. + login(): + Validates and logs into the Firstrade platform. + login_two(code): + Finishes the login process to the Firstrade platform. When using email or phone mfa. + delete_cookies(): + Deletes the session cookies. + _load_cookies(): + Checks if session cookies were saved and loads them. + _save_cookies(): + Saves session cookies to a file. + _mask_email(email): + Masks the email for use in the API. + _handle_mfa(): + Handles multi-factor authentication. + """ + + def __init__( + self, + username, + password, + pin=None, + email=None, + phone=None, + mfa_secret=None, + profile_path=None, + ): """ Initializes a new instance of the FTSession class. @@ -19,76 +69,113 @@ def __init__(self, username, password, pin, profile_path=None): username (str): Firstrade login username. password (str): Firstrade login password. pin (str): Firstrade login pin. - persistent_session (bool, optional): Whether the user wants to save the session cookies. + email (str, optional): Firstrade MFA email. + phone (str, optional): Firstrade MFA phone number. profile_path (str, optional): The path where the user wants to save the cookie pkl file. """ self.username = username self.password = password self.pin = pin + self.email = FTSession._mask_email(email) if email is not None else None + self.phone = phone + self.mfa_secret = mfa_secret self.profile_path = profile_path + self.t_token = None + self.otp_options = None + self.login_json = None self.session = requests.Session() - self.login() def login(self): - """Method to validate and login to the Firstrade platform.""" - headers = urls.session_headers() - cookies = self.load_cookies() - cookies = requests.utils.cookiejar_from_dict(cookies) - self.session.cookies.update(cookies) - response = self.session.get( - url=urls.get_xml(), headers=urls.session_headers(), cookies=cookies - ) - if response.status_code != 200: - raise Exception( - "Login failed. Check your credentials or internet connection." - ) - if "/cgi-bin/sessionfailed?reason=6" in response.text: - self.session.get(url=urls.login(), headers=headers) - data = { - "redirect": "", - "ft_locale": "en-us", - "login.x": "Log In", - "username": r"" + self.username, - "password": r"" + self.password, - "destination_page": "home", - } + """ + Validates and logs into the Firstrade platform. - self.session.post( - url=urls.login(), - headers=headers, - cookies=self.session.cookies, - data=data, - ) - data = { - "destination_page": "home", - "pin": self.pin, - "pin.x": "++OK++", - "sring": "0", - "pin": self.pin, - } + This method sets up the session headers, loads cookies if available, and performs the login request. + It handles multi-factor authentication (MFA) if required. - self.session.post( - url=urls.pin(), headers=headers, cookies=self.session.cookies, data=data - ) - self.save_cookies() + Raises: + LoginRequestError: If the login request fails with a non-200 status code. + LoginResponseError: If the login response contains an error message. + """ + self.session.headers = urls.session_headers() + ftat = self._load_cookies() + if ftat != "": + self.session.headers["ftat"] = ftat + response = self.session.get(url="https://api3x.firstrade.com/", timeout=10) + self.session.headers["access-token"] = urls.access_token() + + data = { + "username": r"" + self.username, + "password": r"" + self.password, + } + + response = self.session.post( + url=urls.login(), + data=data, + ) + try: + self.login_json = response.json() + except json.decoder.JSONDecodeError: + raise LoginResponseError("Invalid JSON is your account funded?") if ( - "/cgi-bin/sessionfailed?reason=6" - in self.session.get( - url=urls.get_xml(), headers=urls.session_headers(), cookies=cookies - ).text + "mfa" not in self.login_json + and "ftat" in self.login_json + and self.login_json["error"] == "" ): - raise Exception("Login failed. Check your credentials.") + self.session.headers["sid"] = self.login_json["sid"] + return False + self.t_token = self.login_json.get("t_token") + if self.mfa_secret is None: + self.otp_options = self.login_json.get("otp") + if response.status_code != 200: + raise LoginRequestError(response.status_code) + if self.login_json["error"] != "": + raise LoginResponseError(self.login_json["error"]) + need_code = self._handle_mfa() + if self.login_json["error"] != "": + raise LoginResponseError(self.login_json["error"]) + if need_code: + return True + self.session.headers["ftat"] = self.login_json["ftat"] + self.session.headers["sid"] = self.login_json["sid"] + self._save_cookies() + return False - def load_cookies(self): + def login_two(self, code): + """Method to finish login to the Firstrade platform.""" + data = { + "otpCode": code, + "verificationSid": self.session.headers["sid"], + "remember_for": "30", + "t_token": self.t_token, + } + response = self.session.post(urls.verify_pin(), data=data) + self.login_json = response.json() + if self.login_json["error"] != "": + raise LoginResponseError(self.login_json["error"]) + self.session.headers["ftat"] = self.login_json["ftat"] + self.session.headers["sid"] = self.login_json["sid"] + self._save_cookies() + + def delete_cookies(self): + """Deletes the session cookies.""" + if self.profile_path is not None: + path = os.path.join(self.profile_path, f"ft_cookies{self.username}.pkl") + else: + path = f"ft_cookies{self.username}.pkl" + os.remove(path) + + def _load_cookies(self): """ Checks if session cookies were saved. Returns: - Dict: Dictionary of cookies. Nom Nom + str: The saved session token. """ - cookies = {} - directory = os.path.abspath(self.profile_path) if self.profile_path is not None else "." - + + ftat = "" + directory = ( + os.path.abspath(self.profile_path) if self.profile_path is not None else "." + ) if not os.path.exists(directory): os.makedirs(directory) @@ -96,10 +183,10 @@ def load_cookies(self): if filename.endswith(f"{self.username}.pkl"): filepath = os.path.join(directory, filename) with open(filepath, "rb") as f: - cookies = pickle.load(f) - return cookies + ftat = pickle.load(f) + return ftat - def save_cookies(self): + def _save_cookies(self): """Saves session cookies to a file.""" if self.profile_path is not None: directory = os.path.abspath(self.profile_path) @@ -109,15 +196,78 @@ def save_cookies(self): else: path = f"ft_cookies{self.username}.pkl" with open(path, "wb") as f: - pickle.dump(self.session.cookies.get_dict(), f) - - def delete_cookies(self): - """Deletes the session cookies.""" - if self.profile_path is not None: - path = os.path.join(self.profile_path, f"ft_cookies{self.username}.pkl") - else: - path = f"ft_cookies{self.username}.pkl" - os.remove(path) + ftat = self.session.headers.get("ftat") + pickle.dump(ftat, f) + + @staticmethod + def _mask_email(email): + """ + Masks the email for use in the API. + + Args: + email (str): The email address to be masked. + + Returns: + str: The masked email address. + """ + local, domain = email.split("@") + masked_local = local[0] + "*" * 4 + domain_name, tld = domain.split(".") + masked_domain = domain_name[0] + "*" * 4 + return f"{masked_local}@{masked_domain}.{tld}" + + def _handle_mfa(self): + """ + Handles multi-factor authentication. + + This method processes the MFA requirements based on the login response and user-provided details. + + Raises: + LoginRequestError: If the MFA request fails with a non-200 status code. + LoginResponseError: If the MFA response contains an error message. + """ + if not self.login_json["mfa"] and self.pin is not None: + data = { + "pin": self.pin, + "remember_for": "30", + "t_token": self.t_token, + } + response = self.session.post(urls.verify_pin(), data=data) + self.login_json = response.json() + elif not self.login_json["mfa"] and ( + self.email is not None or self.phone is not None + ): + for item in self.otp_options: + if item["channel"] == "sms" and self.phone is not None: + if self.phone in item["recipientMask"]: + data = { + "recipientId": item["recipientId"], + "t_token": self.t_token, + } + break + elif item["channel"] == "email" and self.email is not None: + if self.email == item["recipientMask"]: + data = { + "recipientId": item["recipientId"], + "t_token": self.t_token, + } + break + response = self.session.post(urls.request_code(), data=data) + elif self.login_json["mfa"] and self.mfa_secret is not None: + mfa_otp = pyotp.TOTP(self.mfa_secret).now() + data = { + "mfaCode": mfa_otp, + "remember_for": "30", + "t_token": self.t_token, + } + response = self.session.post(urls.verify_pin(), data=data) + self.login_json = response.json() + if self.login_json["error"] == "": + if self.pin or self.mfa_secret is not None: + self.session.headers["sid"] = self.login_json["sid"] + return False + self.session.headers["sid"] = self.login_json["verificationSid"] + return True def __getattr__(self, name): """ @@ -146,74 +296,28 @@ def __init__(self, session): self.session = session self.all_accounts = [] self.account_numbers = [] - self.account_statuses = [] - self.account_balances = [] - self.securities_held = {} - all_account_info = [] - html_string = self.session.get( - url=urls.account_list(), - headers=urls.session_headers(), - cookies=self.session.cookies, - ).text - regex_accounts = re.findall(r"([0-9]+)-", html_string) - - for match in regex_accounts: - self.account_numbers.append(match) - - for account in self.account_numbers: - # reset cookies to base login cookies to run scripts - self.session.cookies.clear() - self.session.cookies.update(self.session.load_cookies()) - # set account to get data for - data = {"accountId": account} - self.session.post( - url=urls.account_status(), - headers=urls.session_headers(), - cookies=self.session.cookies, - data=data, - ) - # request to get account status data - data = {"req": "get_status"} - account_status = self.session.post( - url=urls.status(), - headers=urls.session_headers(), - cookies=self.session.cookies, - data=data, - ).json() - self.account_statuses.append(account_status["data"]) - data = {"page": "bal", "account_id": account} - account_soup = BeautifulSoup( - self.session.post( - url=urls.get_xml(), - headers=urls.session_headers(), - cookies=self.session.cookies, - data=data, - ).text, - "xml", - ) - balance = account_soup.find("total_account_value").text - self.account_balances.append(balance) - all_account_info.append( - { - account: { - "Balance": balance, - "Status": { - "primary": account_status["data"]["primary"], - "domestic": account_status["data"]["domestic"], - "joint": account_status["data"]["joint"], - "ira": account_status["data"]["ira"], - "hasMargin": account_status["data"]["hasMargin"], - "opLevel": account_status["data"]["opLevel"], - "p_country": account_status["data"]["p_country"], - "mrgnStatus": account_status["data"]["mrgnStatus"], - "opStatus": account_status["data"]["opStatus"], - "margin_id": account_status["data"]["margin_id"], - }, - } - } - ) - - self.all_accounts = all_account_info + self.account_balances = {} + response = self.session.get(url=urls.user_info()) + self.user_info = response.json() + response = self.session.get(urls.account_list()) + if response.status_code != 200 or response.json()["error"] != "": + raise AccountResponseError(response.json()["error"]) + self.all_accounts = response.json() + for item in self.all_accounts["items"]: + self.account_numbers.append(item["account"]) + self.account_balances[item["account"]] = item["total_value"] + + def get_account_balances(self, account): + """Gets account balances for a given account. + + Args: + account (str): Account number of the account you want to get balances for. + + Returns: + dict: Dict of the response from the API. + """ + response = self.session.get(urls.account_balances(account)) + return response.json() def get_positions(self, account): """Gets currently held positions for a given account. @@ -222,36 +326,113 @@ def get_positions(self, account): account (str): Account number of the account you want to get positions for. Returns: - self.securities_held {dict}: - Dict of held positions with the pos. ticker as the key. + dict: Dict of the response from the API. + """ + + response = self.session.get(urls.account_positions(account)) + return response.json() + + def get_account_history(self, account, date_range="ytd", custom_range=None): + """Gets account history for a given account. + + Args: + account (str): Account number of the account you want to get history for. + range (str): The range of the history. Defaults to "ytd". + Available options are + ["today", "1w", "1m", "2m", "mtd", "ytd", "ly", "cust"]. + custom_range (str): The custom range of the history. + Defaults to None. If range is "cust", + this parameter is required. + Format: ["YYYY-MM-DD", "YYYY-MM-DD"]. + + Returns: + dict: Dict of the response from the API. + """ + if date_range == "cust" and custom_range is None: + raise ValueError("Custom range is required when date_range is 'cust'.") + response = self.session.get( + urls.account_history(account, date_range, custom_range) + ) + return response.json() + + def get_orders(self, account): + """ + Retrieves existing order data for a given account. + + Args: + ft_session (FTSession): The session object used for making HTTP requests to Firstrade. + account (str): Account number of the account to retrieve orders for. + + Returns: + list: A list of dictionaries, each containing details about an order. """ + + response = self.session.get(url=urls.order_list(account)) + return response.json() + + def cancel_order(self, order_id): + """ + Cancels an existing order. + + Args: + order_id (str): The order ID to cancel. + + Returns: + dict: A dictionary containing the response data. + """ + data = { - "page": "pos", - "accountId": str(account), + "order_id": order_id, } - position_soup = BeautifulSoup( - self.session.post( - url=urls.get_xml(), - headers=urls.session_headers(), - data=data, - cookies=self.session.cookies, - ).text, - "xml", - ) - tickers = position_soup.find_all("symbol") - quantity = position_soup.find_all("quantity") - price = position_soup.find_all("price") - change = position_soup.find_all("change") - change_percent = position_soup.find_all("changepercent") - vol = position_soup.find_all("vol") - for i, ticker in enumerate(tickers): - ticker = ticker.text - self.securities_held[ticker] = { - "quantity": quantity[i].text, - "price": price[i].text, - "change": change[i].text, - "change_percent": change_percent[i].text, - "vol": vol[i].text, - } - return self.securities_held + response = self.session.post(url=urls.cancel_order(), data=data) + return response.json() + + def get_balance_overview(self, account, keywords=None): + """ + Returns a filtered, flattened view of useful balance fields. + + This is a convenience helper over `get_account_balances` to quickly + surface likely relevant numbers such as cash, available cash, and + buying power without needing to know the exact response structure. + + Args: + account (str): Account number to query balances for. + keywords (list[str], optional): Additional case-insensitive substrings + to match in keys. Defaults to a sensible set for balances. + + Returns: + dict: A dict mapping dot-notated keys to values from the balances + response where the key path contains any of the keywords. + """ + if keywords is None: + keywords = [ + "cash", + "avail", + "withdraw", + "buying", + "bp", + "equity", + "value", + "margin", + ] + + payload = self.get_account_balances(account) + + filtered = {} + + def _walk(node, path): + if isinstance(node, dict): + for k, v in node.items(): + _walk(v, path + [str(k)]) + elif isinstance(node, list): + for i, v in enumerate(node): + _walk(v, path + [str(i)]) + else: + key_path = ".".join(path) + low = key_path.lower() + if any(sub in low for sub in keywords): + filtered[key_path] = node + + _walk(payload, []) + return filtered diff --git a/firstrade/exceptions.py b/firstrade/exceptions.py new file mode 100644 index 0000000..3950acd --- /dev/null +++ b/firstrade/exceptions.py @@ -0,0 +1,55 @@ +class QuoteError(Exception): + """Base class for exceptions in the Quote module.""" + + +class QuoteRequestError(QuoteError): + """Exception raised for errors in the HTTP request during a Quote.""" + + def __init__(self, status_code, message="Error in HTTP request"): + self.status_code = status_code + self.message = f"{message}. HTTP status code: {status_code}" + super().__init__(self.message) + + +class QuoteResponseError(QuoteError): + """Exception raised for errors in the API response.""" + + def __init__(self, symbol, error_message): + self.symbol = symbol + self.message = f"Failed to get data for {symbol}. API returned the following error: {error_message}" + super().__init__(self.message) + + +class LoginError(Exception): + """Exception raised for errors in the login process.""" + + +class LoginRequestError(LoginError): + """Exception raised for errors in the HTTP request during login.""" + + def __init__(self, status_code, message="Error in HTTP request during login"): + self.status_code = status_code + self.message = f"{message}. HTTP status code: {status_code}" + super().__init__(self.message) + + +class LoginResponseError(LoginError): + """Exception raised for errors in the API response during login.""" + + def __init__(self, error_message): + self.message = ( + f"Failed to login. API returned the following error: {error_message}" + ) + super().__init__(self.message) + + +class AccountError(Exception): + """Base class for exceptions in the Account module.""" + + +class AccountResponseError(AccountError): + """Exception raised for errors in the API response when getting account data.""" + + def __init__(self, error_message): + self.message = f"Failed to get account data. API returned the following error: {error_message}" + super().__init__(self.message) diff --git a/firstrade/order.py b/firstrade/order.py index a3efe9c..6e9050e 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -1,15 +1,20 @@ from enum import Enum -from bs4 import BeautifulSoup - from firstrade import urls from firstrade.account import FTSession class PriceType(str, Enum): """ - This is an :class: 'enum.Enum' - that contains the valid price types for an order. + Enum for valid price types in an order. + + Attributes: + MARKET (str): Market order, executed at the current market price. + LIMIT (str): Limit order, executed at a specified price or better. + STOP (str): Stop order, becomes a market order once a specified price is reached. + STOP_LIMIT (str): Stop-limit order, becomes a limit order once a specified price is reached. + TRAILING_STOP_DOLLAR (str): Trailing stop order with a specified dollar amount. + TRAILING_STOP_PERCENT (str): Trailing stop order with a specified percentage. """ LIMIT = "2" @@ -22,8 +27,14 @@ class PriceType(str, Enum): class Duration(str, Enum): """ - This is an :class:'~enum.Enum' - that contains the valid durations for an order. + Enum for valid order durations. + + Attributes: + DAY (str): Day order. + GT90 (str): Good till 90 days order. + PRE_MARKET (str): Pre-market order. + AFTER_MARKET (str): After-market order. + DAY_EXT (str): Day extended order. """ DAY = "0" @@ -35,193 +46,201 @@ class Duration(str, Enum): class OrderType(str, Enum): """ - This is an :class:'~enum.Enum' - that contains the valid order types for an order. + Enum for valid order types. + + Attributes: + BUY (str): Buy order. + SELL (str): Sell order. + SELL_SHORT (str): Sell short order. + BUY_TO_COVER (str): Buy to cover order. + BUY_OPTION (str): Buy option order. + SELL_OPTION (str): Sell option order. """ BUY = "B" SELL = "S" SELL_SHORT = "SS" BUY_TO_COVER = "BC" + BUY_OPTION = "BO" + SELL_OPTION = "SO" + + +class OrderInstructions(str, Enum): + """ + Enum for valid order instructions. + + Attributes: + AON (str): All or none. + OPG (str): At the Open. + CLO (str): At the Close. + """ + + AON = "1" + OPG = "4" + CLO = "5" + + +class OptionType(str, Enum): + """ + Enum for valid option types. + + Attributes: + CALL (str): Call option. + PUT (str): Put option. + """ + + CALL = "C" + PUT = "P" class Order: """ - This class contains information about an order. - It also contains a method to place an order. + Represents an order with methods to place it. + + Attributes: + ft_session (FTSession): The session object for placing orders. """ def __init__(self, ft_session: FTSession): self.ft_session = ft_session - self.order_confirmation = {} def place_order( self, - account, - symbol, + account: str, + symbol: str, price_type: PriceType, order_type: OrderType, - quantity, duration: Duration, - price=0.00, - dry_run=True, - notional=False, + quantity: int = 0, + price: float = 0.00, + stop_price: float = None, + dry_run: bool = True, + notional: bool = False, + order_instruction: OrderInstructions = "0", ): """ Builds and places an order. - :attr: 'order_confirmation` - contains the order confirmation data after order placement. Args: - account (str): Account number of the account to place the order in. - symbol (str): Ticker to place the order for. - order_type (PriceType): Price Type i.e. LIMIT, MARKET, STOP, etc. - quantity (float): The number of shares to buy. - duration (Duration): Duration of the order i.e. DAY, GT90, etc. - price (float, optional): The price to buy the shares at. Defaults to 0.00. - dry_run (bool, optional): Whether you want the order to be placed or not. - Defaults to True. + account (str): The account number to place the order in. + symbol (str): The ticker symbol for the order. + price_type (PriceType): The price type for the order (e.g., LIMIT, MARKET, STOP). + order_type (OrderType): The type of order (e.g., BUY, SELL). + duration (Duration): The duration of the order (e.g., DAY, GT90). + quantity (int, optional): The number of shares to buy or sell. Defaults to 0. + price (float, optional): The price at which to buy or sell the shares. Defaults to 0.00. + stop_price (float, optional): The stop price for stop orders. Defaults to None. + dry_run (bool, optional): If True, the order will not be placed but will be built and validated. Defaults to True. + notional (bool, optional): If True, the order will be placed based on a notional dollar amount rather than share quantity. Defaults to False. + order_instruction (OrderInstructions, optional): Additional order instructions (e.g., AON, OPG). Defaults to "0". + + Raises: + ValueError: If AON orders are not limit orders or if AON orders have a quantity of 100 shares or less. + PreviewOrderError: If the order preview fails. + PlaceOrderError: If the order placement fails. Returns: - Order:order_confirmation: Dictionary containing the order confirmation data. + dict: A dictionary containing the order confirmation data. """ - if dry_run: - previewOrders = "1" - else: - previewOrders = "" - - if price_type == PriceType.MARKET: + if price_type == PriceType.MARKET and not notional: price = "" + if order_instruction == OrderInstructions.AON and price_type != PriceType.LIMIT: + raise ValueError("AON orders must be a limit order.") + if order_instruction == OrderInstructions.AON and quantity <= 100: + raise ValueError("AON orders must be greater than 100 shares.") data = { - "submiturl": "/cgi-bin/orderbar", - "orderbar_clordid": "", - "orderbar_accountid": "", - "notional": "yes" if notional else "", - "stockorderpage": "yes", - "submitOrders": "1", - "previewOrders": previewOrders, - "lotMethod": "1", - "accountType": "1", - "quoteprice": "", - "viewederror": "", - "stocksubmittedcompanyname1": "", - "accountId": account, - "transactionType": order_type, - "quantity": quantity, "symbol": symbol, - "priceType": price_type, - "limitPrice": price, + "transaction": order_type, + "shares": quantity, "duration": duration, - "qualifier": "0", - "cond_symbol0_0": "", - "cond_type0_0": "2", - "cond_compare_type0_0": "2", - "cond_compare_value0_0": "", - "cond_and_or0": "1", - "cond_symbol0_1": "", - "cond_type0_1": "2", - "cond_compare_type0_1": "2", - "cond_compare_value0_1": "", + "preview": "true", + "instructions": order_instruction, + "account": account, + "price_type": price_type, + "limit_price": "0", } + if notional: + data["dollar_amount"] = price + del data["shares"] + if price_type in [PriceType.LIMIT, PriceType.STOP_LIMIT]: + data["limit_price"] = price + if price_type in [PriceType.STOP, PriceType.STOP_LIMIT]: + data["stop_price"] = stop_price + response = self.ft_session.post(url=urls.order(), data=data) + if response.status_code != 200 or response.json()["error"] != "": + return response.json() + preview_data = response.json() + if dry_run: + return preview_data + data["preview"] = "false" + data["stage"] = "P" + response = self.ft_session.post(url=urls.order(), data=data) + return response.json() - order_data = BeautifulSoup( - self.ft_session.post( - url=urls.orderbar(), headers=urls.session_headers(), data=data - ).text, - "xml", - ) - order_confirmation = {} - order_success = order_data.find("success").text.strip() - order_confirmation["success"] = order_success - action_data = order_data.find("actiondata").text.strip() - if order_success != "No": - # Extract the table data - table_start = action_data.find("") + len("") - table_data = action_data[table_start:table_end] - table_data = BeautifulSoup(table_data, "xml") - titles = table_data.find_all("th") - data = table_data.find_all("td") - for i, title in enumerate(titles): - order_confirmation[f"{title.get_text()}"] = data[i].get_text() - if not dry_run: - start_index = action_data.find( - "Your order reference number is: " - ) + len("Your order reference number is: ") - end_index = action_data.find("", start_index) - order_number = action_data[start_index:end_index] - else: - start_index = action_data.find('id="') + len('id="') - end_index = action_data.find('" style=', start_index) - order_number = action_data[start_index:end_index] - order_confirmation["orderid"] = order_number - else: - order_confirmation["actiondata"] = action_data - order_confirmation["errcode"] = order_data.find("errcode").text.strip() - self.order_confirmation = order_confirmation - - -def get_orders(ft_session, account): - """ - Retrieves existing order data for a given account. + def place_option_order( + self, + account: str, + option_symbol: str, + price_type: PriceType, + order_type: OrderType, + contracts: int, + duration: Duration, + stop_price: float = None, + price: float = 0.00, + dry_run: bool = True, + order_instruction: OrderInstructions = "0", + ): + """ + Builds and places an option order. + + Args: + account (str): The account number to place the order in. + option_symbol (str): The option ticker symbol for the order. + price_type (PriceType): The price type for the order (e.g., LIMIT, MARKET, STOP). + order_type (OrderType): The type of order (e.g., BUY, SELL). + contracts (int): The number of option contracts to buy or sell. + duration (Duration): The duration of the order (e.g., DAY, GT90). + stop_price (float, optional): The stop price for stop orders. Defaults to None. + price (float, optional): The price at which to buy or sell the option contracts. Defaults to 0.00. + dry_run (bool, optional): If True, the order will not be placed but will be built and validated. Defaults to True. + order_instruction (OrderInstructions, optional): Additional order instructions (e.g., AON, OPG). Defaults to "0". + + Raises: + ValueError: If AON orders are not limit orders or if AON orders have a quantity of 100 contracts or less. + PreviewOrderError: If there is an error during the preview of the order. + PlaceOrderError: If there is an error during the placement of the order. - Args: - ft_session (FTSession): The session object used for making HTTP requests to Firstrade. - account (str): Account number of the account to retrieve orders for. + Returns: + dict: A dictionary containing the order confirmation data. + """ - Returns: - list: A list of dictionaries, each containing details about an order. - """ + if order_instruction == OrderInstructions.AON and price_type != PriceType.LIMIT: + raise ValueError("AON orders must be a limit order.") + if order_instruction == OrderInstructions.AON and contracts <= 100: + raise ValueError("AON orders must be greater than 100 shares.") - # Data dictionary to send with the request - data = { - 'accountId': account, - } - - # Post request to retrieve the order data - response = ft_session.post(url=urls.order_list(), headers=urls.session_headers(), data=data).text - - # Parse the response using BeautifulSoup - soup = BeautifulSoup(response, "html.parser") - - # Find the table containing orders - table = soup.find('table', class_='tablesorter') - if not table: - return [] - - rows = table.find_all('tr')[1:] # skip the header row - - orders = [] - for row in rows: - try: - cells = row.find_all('td') - tooltip_content = row.find('a', {'class': 'info'}).get('onmouseover') - tooltip_soup = BeautifulSoup(tooltip_content.split('tooltip.show(')[1].strip("');"), 'html.parser') - order_ref = tooltip_soup.find(text=lambda text: 'Order Ref' in text) - order_ref_number = order_ref.split('#: ')[1] if order_ref else None - status = cells[8] - # print(status) - sub_status = status.find('strong') - # print(sub_status) - sub_status = sub_status.get_text(strip=True) - # print(sub_status) - status = status.find('strong').get_text(strip=True) if status.find('strong') else status.get_text(strip=True) - order = { - 'Date/Time': cells[0].get_text(strip=True), - 'Reference': order_ref_number, - 'Transaction': cells[1].get_text(strip=True), - 'Quantity': int(cells[2].get_text(strip=True)), - 'Symbol': cells[3].get_text(strip=True), - 'Type': cells[4].get_text(strip=True), - 'Price': float(cells[5].get_text(strip=True)), - 'Duration': cells[6].get_text(strip=True), - 'Instr.': cells[7].get_text(strip=True), - 'Status': status, - } - orders.append(order) - except Exception as e: - print(f"Error parsing order: {e}") - - return orders + data = { + "duration": duration, + "instructions": order_instruction, + "transaction": order_type, + "contracts": contracts, + "symbol": option_symbol, + "preview": "true", + "account": account, + "price_type": price_type, + } + if price_type in [PriceType.LIMIT, PriceType.STOP_LIMIT]: + data["limit_price"] = price + if price_type in [PriceType.STOP, PriceType.STOP_LIMIT]: + data["stop_price"] = stop_price + + response = self.ft_session.post(url=urls.option_order(), data=data) + if response.status_code != 200 or response.json()["error"] != "": + return response.json() + if dry_run: + return response.json() + data["preview"] = "false" + response = self.ft_session.post(url=urls.option_order(), data=data) + return response.json() diff --git a/firstrade/symbols.py b/firstrade/symbols.py index 2df0d00..b3b2241 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -1,61 +1,172 @@ -from bs4 import BeautifulSoup - from firstrade import urls from firstrade.account import FTSession +from firstrade.exceptions import QuoteRequestError, QuoteResponseError class SymbolQuote: """ - Dataclass containing quote information for a symbol. + Data class representing a stock quote for a given symbol. Attributes: - ft_session (FTSession): - The session object used for making HTTP requests to Firstrade. + ft_session (FTSession): The session object used for making HTTP requests to Firstrade. symbol (str): The symbol for which the quote information is retrieved. - exchange (str): The exchange where the symbol is traded. - bid (float): The bid price for the symbol. - ask (float): The ask price for the symbol. + sec_type (str): The security type of the symbol. + tick (str): The tick size of the symbol. + bid (int): The bid price for the symbol. + bid_size (int): The size of the bid. + ask (int): The ask price for the symbol. + ask_size (int): The size of the ask. last (float): The last traded price for the symbol. change (float): The change in price for the symbol. high (float): The highest price for the symbol during the trading day. low (float): The lowest price for the symbol during the trading day. + bid_mmid (str): The market maker ID for the bid. + ask_mmid (str): The market maker ID for the ask. + last_mmid (str): The market maker ID for the last trade. + last_size (int): The size of the last trade. + change_color (str): The color indicating the change in price. volume (str): The volume of shares traded for the symbol. + today_close (float): The closing price for the symbol today. + open (str): The opening price for the symbol. + quote_time (str): The time of the quote. + last_trade_time (str): The time of the last trade. company_name (str): The name of the company associated with the symbol. - real_time (bool): If the quote is real-time or not - fractional (bool): If the stock can be traded fractionally, or not + exchange (str): The exchange where the symbol is traded. + has_option (bool): Indicates if the symbol has options. + is_etf (bool): Indicates if the symbol is an ETF. + is_fractional (bool): Indicates if the stock can be traded fractionally. + realtime (str): Indicates if the quote is real-time. + nls (str): Nasdaq last sale. + shares (int): The number of shares. """ - def __init__(self, ft_session: FTSession, symbol: str): + def __init__(self, ft_session: FTSession, account: str, symbol: str): """ Initializes a new instance of the SymbolQuote class. + Args: + ft_session (FTSession): The session object used for making HTTP requests to Firstrade. + account (str): The account number for which the quote information is retrieved. + symbol (str): The symbol for which the quote information is retrieved. + + Raises: + QuoteRequestError: If the quote request fails with a non-200 status code. + QuoteResponseError: If the quote response contains an error message. + """ + self.ft_session = ft_session + response = self.ft_session.get(url=urls.quote(account, symbol)) + if response.status_code != 200: + raise QuoteRequestError(response.status_code) + if response.json().get("error", "") != "": + raise QuoteResponseError(symbol, response.json()["error"]) + self.symbol = response.json()["result"]["symbol"] + self.sec_type = response.json()["result"]["sec_type"] + self.tick = response.json()["result"]["tick"] + self.bid = response.json()["result"]["bid"] + self.bid_size = response.json()["result"]["bid_size"] + self.ask = response.json()["result"]["ask"] + self.ask_size = response.json()["result"]["ask_size"] + self.last = response.json()["result"]["last"] + self.change = response.json()["result"]["change"] + self.high = response.json()["result"]["high"] + self.low = response.json()["result"]["low"] + self.bid_mmid = response.json()["result"]["bid_mmid"] + self.ask_mmid = response.json()["result"]["ask_mmid"] + self.last_mmid = response.json()["result"]["last_mmid"] + self.last_size = response.json()["result"]["last_size"] + self.change_color = response.json()["result"]["change_color"] + self.volume = response.json()["result"]["vol"] + self.today_close = response.json()["result"]["today_close"] + self.open = response.json()["result"]["open"] + self.quote_time = response.json()["result"]["quote_time"] + self.last_trade_time = response.json()["result"]["last_trade_time"] + self.company_name = response.json()["result"]["company_name"] + self.exchange = response.json()["result"]["exchange"] + self.has_option = response.json()["result"]["has_option"] + self.is_etf = bool(response.json()["result"]["is_etf"]) + self.is_fractional = bool(response.json()["result"]["is_fractional"]) + self.realtime = response.json()["result"]["realtime"] + self.nls = response.json()["result"]["nls"] + self.shares = response.json()["result"]["shares"] + + +class OptionQuote: + """ + Data class representing an option quote for a given symbol. + + Attributes: + ft_session (FTSession): The session object used for making HTTP requests to Firstrade. + symbol (str): The symbol for which the option quote information is retrieved. + option_dates (dict): A dict of expiration dates for options on the given symbol. + """ + + def __init__(self, ft_session: FTSession, symbol: str): + """ + Initializes a new instance of the OptionQuote class. + Args: ft_session (FTSession): The session object used for making HTTP requests to Firstrade. - symbol (str): The symbol for which the quote information is retrieved. + symbol (str): The symbol for which the option quote information is retrieved. """ self.ft_session = ft_session self.symbol = symbol - symbol_data = self.ft_session.get( - url=urls.quote(self.symbol), headers=urls.session_headers() - ) - soup = BeautifulSoup(symbol_data.text, "xml") - quote = soup.find("quote") - self.symbol = quote.find("symbol").text - self.exchange = quote.find("exchange").text - self.bid = float(quote.find("bid").text.replace(",", "")) - self.ask = float(quote.find("ask").text.replace(",", "")) - self.last = float(quote.find("last").text.replace(",", "")) - self.change = float(quote.find("change").text.replace(",", "")) - if quote.find("high").text == "N/A": - self.high = None - else: - self.high = float(quote.find("high").text.replace(",", "")) - if quote.find("low").text == "N/A": - self.low = "None" - else: - self.low = float(quote.find("low").text.replace(",", "")) - self.volume = quote.find("vol").text - self.company_name = quote.find("companyname").text - self.real_time = quote.find("realtime").text == "T" - self.fractional = quote.find("fractional").text == "T" + self.option_dates = self.get_option_dates(symbol) + + def get_option_dates(self, symbol: str): + """ + Retrieves the expiration dates for options on a given symbol. + + Args: + symbol (str): The symbol for which the expiration dates are retrieved. + + Returns: + dict: A dict of expiration dates and other information for options on the given symbol. + + Raises: + QuoteRequestError: If the request for option dates fails with a non-200 status code. + QuoteResponseError: If the response for option dates contains an error message. + """ + response = self.ft_session.get(url=urls.option_dates(symbol)) + return response.json() + + def get_option_quote(self, symbol: str, date: str): + """ + Retrieves the quote for a given option symbol. + + Args: + symbol (str): The symbol for which the quote is retrieved. + + Returns: + dict: A dictionary containing the quote and other information for the given option symbol. + + Raises: + QuoteRequestError: If the request for the option quote fails with a non-200 status code. + QuoteResponseError: If the response for the option quote contains an error message. + """ + response = self.ft_session.get(url=urls.option_quotes(symbol, date)) + return response.json() + + def get_greek_options(self, symbol: str, exp_date: str): + """ + Retrieves the greeks for options on a given symbol. + + Args: + symbol (str): The symbol for which the greeks are retrieved. + exp_date (str): The expiration date of the options. + + Returns: + dict: A dictionary containing the greeks for the options on the given symbol. + + Raises: + QuoteRequestError: If the request for the greeks fails with a non-200 status code. + QuoteResponseError: If the response for the greeks contains an error message. + """ + data = { + "type": "chain", + "chains_range": "A", + "root_symbol": symbol, + "exp_date": exp_date, + } + response = self.ft_session.post(url=urls.greek_options(), data=data) + return response.json() diff --git a/firstrade/urls.py b/firstrade/urls.py index 3b5bac8..e036db3 100644 --- a/firstrade/urls.py +++ b/firstrade/urls.py @@ -1,46 +1,80 @@ -def get_xml(): - return "https://invest.firstrade.com/cgi-bin/getxml" +def login(): + return "https://api3x.firstrade.com/sess/login" -def login(): - return "https://invest.firstrade.com/cgi-bin/login" +def request_code(): + return "https://api3x.firstrade.com/sess/request_code" + +def verify_pin(): + return "https://api3x.firstrade.com/sess/verify_pin" -def pin(): - return "https://invest.firstrade.com/cgi-bin/enter_pin?destination_page=home" + +def user_info(): + return "https://api3x.firstrade.com/private/userinfo" def account_list(): - return "https://invest.firstrade.com/cgi-bin/getaccountlist" + return "https://api3x.firstrade.com/private/acct_list" + + +def account_balances(account): + return f"https://api3x.firstrade.com/private/balances?account={account}" + + +def account_positions(account): + return ( + f"https://api3x.firstrade.com/private/positions?account={account}&per_page=200" + ) + + +def quote(account, symbol): + return f"https://api3x.firstrade.com/public/quote?account={account}&q={symbol}" -def quote(symbol): - return f"https://invest.firstrade.com/cgi-bin/getxml?page=quo"eSymbol={symbol}" +def order(): + return "https://api3x.firstrade.com/private/stock_order" -def orderbar(): - return "https://invest.firstrade.com/cgi-bin/orderbar" +def order_list(account): + return f"https://api3x.firstrade.com/private/order_status?account={account}" -def account_status(): - return "https://invest.firstrade.com/cgi-bin/account_status" +def account_history(account, date_range, custom_range): + if custom_range is None: + return f"https://api3x.firstrade.com/private/account_history?range={date_range}&page=1&account={account}&per_page=1000" + return f"https://api3x.firstrade.com/private/account_history?range={date_range}&range_arr[]={custom_range[0]}&range_arr[]={custom_range[1]}&page=1&account={account}&per_page=1000" -def order_list(): - return "https://invest.firstrade.com/cgi-bin/orderstatus" +def cancel_order(): + return "https://api3x.firstrade.com/private/cancel_order" -def status(): - return "https://invest.firstrade.com/scripts/profile/margin_v2.php" + +def option_dates(symbol): + return f"https://api3x.firstrade.com/public/oc?m=get_exp_dates&root_symbol={symbol}" + + +def option_quotes(symbol, date): + return f"https://api3x.firstrade.com/public/oc?m=get_oc&root_symbol={symbol}&exp_date={date}&chains_range=A" + + +def greek_options(): + return "https://api3x.firstrade.com/private/greekoptions/analytical" + + +def option_order(): + return "https://api3x.firstrade.com/private/option_order" def session_headers(): headers = { - "Accept": "*/*", - "Accept-Encoding": "gzip, deflate, br", - "Accept-Language": "en-US,en;q=0.9", - "Host": "invest.firstrade.com", - "Referer": "https://invest.firstrade.com/cgi-bin/main", - "Connection": "keep-alive", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.81", + "Accept-Encoding": "gzip", + "Connection": "Keep-Alive", + "Host": "api3x.firstrade.com", + "User-Agent": "okhttp/4.9.2", } return headers + + +def access_token(): + return "833w3XuIFycv18ybi" diff --git a/setup.py b/setup.py index 6b0273b..6727c2d 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="firstrade", - version="0.0.17", + version="0.0.34", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,7 +13,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0017.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0034.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "beautifulsoup4", "lxml"], packages=["firstrade"], @@ -27,5 +27,7 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], ) diff --git a/test.py b/test.py index 2718dd9..dc1bbc2 100644 --- a/test.py +++ b/test.py @@ -1,8 +1,11 @@ from firstrade import account, order, symbols -from firstrade.order import get_orders # Create a session -ft_ss = account.FTSession(username="", password="", pin="") +ft_ss = account.FTSession(username="", password="", email="", profile_path="") +need_code = ft_ss.login() +if need_code: + code = input("Please enter the pin sent to your email/phone: ") + ft_ss.login_two(code) # Get account data ft_accounts = account.FTAccountData(ft_ss) @@ -19,55 +22,121 @@ print(ft_accounts.account_balances) # Get quote for INTC -quote = symbols.SymbolQuote(ft_ss, "INTC") +quote = symbols.SymbolQuote(ft_ss, ft_accounts.account_numbers[0], "INTC") print(f"Symbol: {quote.symbol}") +print(f"Tick: {quote.tick}") print(f"Exchange: {quote.exchange}") print(f"Bid: {quote.bid}") print(f"Ask: {quote.ask}") print(f"Last: {quote.last}") +print(f"Bid Size: {quote.bid_size}") +print(f"Ask Size: {quote.ask_size}") +print(f"Last Size: {quote.last_size}") +print(f"Bid MMID: {quote.bid_mmid}") +print(f"Ask MMID: {quote.ask_mmid}") +print(f"Last MMID: {quote.last_mmid}") print(f"Change: {quote.change}") print(f"High: {quote.high}") print(f"Low: {quote.low}") +print(f"Change Color: {quote.change_color}") print(f"Volume: {quote.volume}") +print(f"Quote Time: {quote.quote_time}") +print(f"Last Trade Time: {quote.last_trade_time}") +print(f"Real Time: {quote.realtime}") +print(f"Fractional: {quote.is_fractional}") print(f"Company Name: {quote.company_name}") # Get positions and print them out for an account. positions = ft_accounts.get_positions(account=ft_accounts.account_numbers[1]) -for key in ft_accounts.securities_held: +print(positions) +for item in positions["items"]: print( - f"Quantity {ft_accounts.securities_held[key]['quantity']} of security {key} held in account {ft_accounts.account_numbers[1]}" + f"Quantity {item['quantity']} of security {item['symbol']} held in account {ft_accounts.account_numbers[1]}" ) +# Get account history (past 200) +history = ft_accounts.get_account_history( + account=ft_accounts.account_numbers[0], + date_range="cust", + custom_range=["2024-01-01", "2024-06-30"], +) + +for item in history["items"]: + print( + f"Transaction: {item['symbol']} on {item['report_date']} for {item['amount']}." + ) + + # Create an order object. ft_order = order.Order(ft_ss) -# Place order and print out order confirmation data. -ft_order.place_order( +# Place dry run order and print out order confirmation data. +order_conf = ft_order.place_order( ft_accounts.account_numbers[0], symbol="INTC", - price_type=order.PriceType.MARKET, + price_type=order.PriceType.LIMIT, order_type=order.OrderType.BUY, - quantity=1, duration=order.Duration.DAY, + quantity=1, dry_run=True, ) -# Print Order data Dict -print(ft_order.order_confirmation) +print(order_conf) -# Check if order was successful -if ft_order.order_confirmation["success"] == "Yes": - print("Order placed successfully.") - # Print Order ID - print(f"Order ID: {ft_order.order_confirmation['orderid']}.") +if "order_id" not in order_conf["result"]: + print("Dry run complete.") + print(order_conf["result"]) else: - print("Failed to place order.") - # Print errormessage - print(ft_order.order_confirmation["actiondata"]) + print("Order placed successfully.") + print(f"Order ID: {order_conf['result']['order_id']}.") + print(f"Order State: {order_conf['result']['state']}.") + +# Cancel placed order +# cancel = ft_accounts.cancel_order(order_conf['result']["order_id"]) +# if cancel["result"]["result"] == "success": +# print("Order cancelled successfully.") +# print(cancel) # Check orders -current_orders = get_orders(ft_ss, ft_accounts.account_numbers[0]) -print(current_orders) +recent_orders = ft_accounts.get_orders(ft_accounts.account_numbers[0]) +print(recent_orders) + +# Get option dates +option_first = symbols.OptionQuote(ft_ss, "INTC") +for item in option_first.option_dates["items"]: + print( + f"Expiration Date: {item['exp_date']} Days Left: {item['day_left']} Expiration Type: {item['exp_type']}" + ) + +# Get option quote +option_quote = option_first.get_option_quote( + "INTC", option_first.option_dates["items"][0]["exp_date"] +) +print(option_quote) + +# Get option greeks +option_greeks = option_first.get_greek_options( + "INTC", option_first.option_dates["items"][0]["exp_date"] +) +print(option_greeks) + +print( + f"Placing dry option order for {option_quote['items'][0]['opt_symbol']} with a price of {option_quote['items'][0]['ask']}." +) +print("Symbol readable ticker 'INTC'") + +# Place dry option order +option_order = ft_order.place_option_order( + account=ft_accounts.account_numbers[0], + option_symbol=option_quote["items"][0]["opt_symbol"], + order_type=order.OrderType.BUY_OPTION, + price_type=order.PriceType.MARKET, + duration=order.Duration.DAY, + contracts=1, + dry_run=True, +) + +print(option_order) # Delete cookies -ft_ss.delete_cookies() \ No newline at end of file +ft_ss.delete_cookies()