diff --git a/.deepsource.toml b/.deepsource.toml deleted file mode 100644 index b8e721c..0000000 --- a/.deepsource.toml +++ /dev/null @@ -1,20 +0,0 @@ -version = 1 - -[[analyzers]] -name = "shell" - -[[analyzers]] -name = "python" - - [analyzers.meta] - runtime_version = "3.x.x" - max_line_length = 150 - -[[analyzers]] -name = "docker" - -[[transformers]] -name = "black" - -[[transformers]] -name = "isort" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 304d7d4..f514b74 100644 --- a/.gitignore +++ b/.gitignore @@ -1,135 +1,2 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class -testme.py -*.pkl - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -user_data_dir -auth.json -*.png +# Created by venv; see https://docs.python.org/3/library/venv.html +* diff --git a/README.md b/README.md index 613e8eb..b01991b 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,43 @@ 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 OHLC data +- 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 OHLC (timestamp, open, high, low, close, volume) - [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: -[](https://github.com/sponsors/maxxrk) \ No newline at end of file +[](https://github.com/sponsors/maxxrk) \ No newline at end of file diff --git a/firstrade/account.py b/firstrade/account.py index bf730bd..38b74b9 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -1,133 +1,391 @@ -import os -import pickle -import re +import json +from pathlib import Path +import pyotp +import logging import requests -from bs4 import BeautifulSoup from firstrade import urls +from firstrade.exceptions import ( + AccountResponseError, + LoginError, + LoginRequestError, + LoginResponseError, +) + +logger = logging.getLogger(__name__) class FTSession: - """Class creating a session for Firstrade.""" + """Class creating a session for Firstrade. - def __init__(self, username, password, pin, profile_path=None): - """ - Initializes a new instance of the FTSession class. + 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, optional): 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. + debug (bool, optional): Log HTTP requests/responses if true. DO NOT POST YOUR LOGS ONLINE. + 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, debug=False): + 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. + _request(method, url, **kwargs): + HTTP requests wrapper to the API. + + """ + + def __init__( + self, + username: str = "", + password: str = "", + pin: str = "", + email: str = "", + phone: str = "", + mfa_secret: str = "", + profile_path: str | None = None, + *, + save_session: bool = False, + debug: bool = False + ) -> None: + """Initialize a new instance of the FTSession class. Args: 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. - profile_path (str, optional): The path where the user wants to save the cookie pkl file. + pin (str, optional): Firstrade login pin. + email (str, optional): Firstrade MFA email. + phone (str, optional): Firstrade MFA phone number. + mfa_secret (str, optional): Firstrade MFA secret key to generate TOTP. + profile_path (str, optional): The path where the user wants to save the cookie json file. + save_session (bool, optional): Save session cookies if true. + debug (bool, optional): Log HTTP requests/responses if true. DO NOT POST YOUR LOGS ONLINE. + """ - self.username = username - self.password = password - self.pin = pin - self.profile_path = profile_path + self.username: str = username + self.password: str = password + self.pin: str = pin + self.email: str = FTSession._mask_email(email) if email else "" + self.phone: str = phone + self.mfa_secret: str = mfa_secret + self.profile_path: str | None = profile_path + self.save_session: bool = save_session # Flag to save session cookies + self.debug: bool = debug + if self.debug: + logging.basicConfig(level=logging.DEBUG) + # Enable HTTP connection debug output + import http.client as http_client + + http_client.HTTPConnection.debuglevel = 1 + # requests logging too + logging.getLogger("requests.packages.urllib3").setLevel(logging.DEBUG) + logging.getLogger("requests.packages.urllib3").propagate = True + self.t_token: str | None = None + self.otp_options: str | list[dict[str, str]] | None = None + self.login_json: dict[str, str] = {} 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 + + def login(self) -> bool: + """Validate and log into the Firstrade platform. + + This method sets up the session headers, loads cookies if available, and performs the login request. + It handles multi-factor authentication (MFA) if required. + + 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.update(urls.session_headers()) + ftat: str = self._load_cookies() + if ftat: + self.session.headers["ftat"] = ftat + response: requests.Response = self._request("get", url="https://api3x.firstrade.com/", timeout=10) # type: ignore[arg-type] + self.session.headers["access-token"] = urls.access_token() + + data: dict[str, str] = { + "username": r"" + self.username, + "password": r"" + self.password, + } + + response: requests.Response = self._request( + method="post", + url=urls.login(), + data=data, ) + try: + self.login_json: dict[str, str] = response.json() + except json.decoder.JSONDecodeError as exc: + error_msg = "Invalid JSON is your account funded?" + raise LoginResponseError(error_msg) from exc + if "mfa" not in self.login_json and "ftat" in self.login_json and not self.login_json["error"]: + self.session.headers["sid"] = self.login_json["sid"] + return False + self.t_token: str | None = self.login_json.get("t_token") + if not self.login_json.get("mfa"): + self.otp_options = self.login_json.get("otp") 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", - } + raise LoginRequestError(response.status_code) + if self.login_json["error"]: + raise LoginResponseError(self.login_json["error"]) + need_code: bool | None = 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"] + if self.save_session: + self._save_cookies() + return False - 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, + def login_two(self, code: str) -> None: + """Finish login to the Firstrade platform.""" + data: dict[str, str | None] = {} + if self.login_json.get("mfa"): + data.update({ + "mfaCode": code, + "remember_for": "30", + "t_token": self.t_token, + }) + else: + data: dict[str, str | None] = { + "otpCode": code, + "verificationSid": self.session.headers["sid"], + "remember_for": "30", + "t_token": self.t_token, } + response: requests.Response = self._request(method="post", url=urls.verify_pin(), data=data) + self.login_json: dict[str, str] = 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"] + if self.save_session: + self._save_cookies() + + def delete_cookies(self) -> None: + """Delete the session cookies.""" + path: Path = Path(self.profile_path) / f"ft_cookies{self.username}.json" if self.profile_path is not None else Path(f"ft_cookies{self.username}.json") + path.unlink() + + def get_tokens(self) -> dict[str, str | bytes | dict[str, str] | None]: + """Return the current session tokens (access_token, ftat, sid and cookies).""" + cookies: dict[str, str] = self.session.cookies.get_dict() + + return { + "access-token": self.session.headers.get("access-token"), + "ftat": self.session.headers.get("ftat"), + "sid": self.session.headers.get("sid"), + "cookies": cookies or "", + } + + def build_session_from_tokens(self, tokens: dict[str, str | bytes | dict[str, str] | None]) -> None: + """Build the session headers and cookies from provided tokens.""" + self.session.headers.update(urls.session_headers()) + if tokens: + access_token = tokens.get("access-token") + ftat_token = tokens.get("ftat") + sid_token = tokens.get("sid") + + if isinstance(access_token, (str, bytes)): + self.session.headers.update({"access-token": access_token}) + if isinstance(ftat_token, (str, bytes)): + self.session.headers.update({"ftat": ftat_token}) + if isinstance(sid_token, (str, bytes)): + self.session.headers.update({"sid": sid_token}) + cookies = tokens.get("cookies") + if isinstance(cookies, dict): + self.session.cookies.update(cookies) # type: ignore[arg-type] + + def _load_cookies(self) -> str | None: + """Check if session cookies were saved. + + Returns + ------- + str: The saved session token. - self.session.post( - url=urls.pin(), headers=headers, cookies=self.session.cookies, data=data - ) - self.save_cookies() - if ( - "/cgi-bin/sessionfailed?reason=6" - in self.session.get( - url=urls.get_xml(), headers=urls.session_headers(), cookies=cookies - ).text - ): - raise Exception("Login failed. Check your credentials.") - - def load_cookies(self): """ - Checks if session cookies were saved. + ftat = "" + directory: Path = Path(self.profile_path) if self.profile_path is not None else Path() + if not directory.exists(): + directory.mkdir(parents=True) + + for filepath in directory.iterdir(): + if filepath.name.endswith(f"{self.username}.json"): + with filepath.open(mode="r") as f: + ftat: str = json.load(fp=f) + return ftat + + def _save_cookies(self) -> str | None: + """Save session cookies to a file.""" + # Allow providing "ftat" token from an external source + if self.save_session: + if self.profile_path: + directory = Path(self.profile_path) + if not directory.exists(): + directory.mkdir(parents=True) + path: Path = directory / f"ft_cookies{self.username}.json" + else: + path = Path(f"ft_cookies{self.username}.json") + with path.open("w") as f: + ftat: str | None = self.session.headers.get("ftat") + json.dump(obj=ftat, fp=f) + + @staticmethod + def _mask_email(email: str) -> str: + """Mask the email for use in the API. + + Args: + email (str): The email address to be masked. Returns: - Dict: Dictionary of cookies. Nom Nom + str: The masked email address. + """ - cookies = {} - directory = os.path.abspath(self.profile_path) if self.profile_path is not None else "." - - if not os.path.exists(directory): - os.makedirs(directory) - - for filename in os.listdir(directory): - 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 - - def save_cookies(self): - """Saves session cookies to a file.""" - if self.profile_path is not None: - directory = os.path.abspath(self.profile_path) - if not os.path.exists(directory): - os.makedirs(directory) - path = os.path.join(self.profile_path, f"ft_cookies{self.username}.pkl") - 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) + local, domain = email.split(sep="@") + masked_local: str = local[0] + "*" * 4 + domain_name, tld = domain.split(".") + masked_domain: str = domain_name[0] + "*" * 4 + return f"{masked_local}@{masked_domain}.{tld}" + + def _handle_mfa(self) -> bool: + """Handle multi-factor authentication. + + This method processes the MFA requirements based on the login response and user-provided details. - def __getattr__(self, name): """ - Forwards unknown attribute access to session object. + response: requests.Response | None = None + data: dict[str, str | None] = {} + if self.pin: + response: requests.Response = self._handle_pin_mfa(data) + self.login_json = response.json() + elif (self.email or self.phone) and not self.login_json.get("mfa"): + response: requests.Response = self._handle_otp_mfa(data) + self.login_json = response.json() + elif self.mfa_secret: + response: requests.Response = self._handle_secret_mfa(data) + self.login_json = response.json() + elif self.login_json.get("mfa"): + pass # MFA handling without user provided secret in login_two + else: + error_msg = "MFA required but no valid MFA method was provided (pin, email/phone, or mfa_secret)." + raise LoginError(error_msg) + + if self.login_json["error"]: + raise LoginResponseError(self.login_json["error"]) + + if self.pin or self.mfa_secret: + self.session.headers["sid"] = self.login_json["sid"] + return False + if self.login_json.get("mfa") and not self.mfa_secret: + return True + self.session.headers["sid"] = self.login_json["verificationSid"] + return True + + def _handle_pin_mfa(self, data: dict[str, str | None]) -> requests.Response: + """Handle PIN-based MFA.""" + data.update({ + "pin": self.pin, + "remember_for": "30", + "t_token": self.t_token, + }) + return self._request("post", urls.verify_pin(), data=data) + + def _handle_otp_mfa(self, data: dict[str, str | None]) -> requests.Response: + """Handle email/phone OTP-based MFA.""" + if not self.otp_options: + error_msg = "No OTP options available." + raise LoginResponseError(error_msg) + + for item in self.otp_options: + if (item["channel"] == "sms" and self.phone and self.phone in item["recipientMask"]) or (item["channel"] == "email" and self.email and self.email == item["recipientMask"]): + data.update({ + "recipientId": item["recipientId"], + "t_token": self.t_token, + }) + break + + return self._request("post", urls.request_code(), data=data) + + def _handle_secret_mfa(self, data: dict[str, str | None]) -> requests.Response: + """Handle MFA secret-based authentication.""" + mfa_otp = pyotp.TOTP(self.mfa_secret).now() + data.update({ + "mfaCode": mfa_otp, + "remember_for": "30", + "t_token": self.t_token, + }) + return self._request("post", urls.verify_pin(), data=data) + + def _request(self, method: str, url: str, **kwargs: object) -> requests.Response: + """Send HTTP request and log the full response content if debug=True.""" + resp = self.session.request(method, url, **kwargs) # type: ignore[no-untyped-call] + + if self.debug: + # Suppress urllib3 / http.client debug so we only see this log + logging.getLogger("urllib3").setLevel(logging.WARNING) + + # Basic request info + logger.debug(f">>> {method.upper()} {url}") + logger.debug(f"<<< Status: {resp.status_code}") + logger.debug(f"<<< Headers: {resp.headers}") + + # Log raw bytes length + try: + logger.debug(f"<<< Raw bytes length: {len(resp.content)}") + except Exception as e: + logger.debug(f"<<< Could not read raw bytes: {e}") + + # Log pretty JSON (if any) + try: + import json as pyjson + + # This automatically uses requests decompression if gzip is set + json_body = resp.json() + pretty = pyjson.dumps(json_body, indent=2) + logger.debug(f"<<< JSON body:\n{pretty}") + except Exception as e: + # If JSON decoding fails, fallback to raw text + try: + logger.debug(f"<<< Body (text):\n{resp.text}") + except Exception as e2: + logger.debug(f"<<< Could not read body text: {e2}") + + return resp + + def __getattr__(self, name: str) -> object: + """Forward unknown attribute access to session object. Args: name (str): The name of the attribute to be accessed. Returns: The value of the requested attribute from the session object. + """ return getattr(self.session, name) @@ -135,123 +393,158 @@ def __getattr__(self, name): class FTAccountData: """Dataclass for storing account information.""" - def __init__(self, session): + def __init__(self, session: requests.Session) -> None: + """Initialize a new instance of the FTAccountData class. + + Args: + session (requests.Session): The session object used for making HTTP requests. + """ - Initializes a new instance of the FTAccountData class. + self.session: requests.Session = session + self.all_accounts: list[dict[str, object]] = [] + self.account_numbers: list[str] = [] + self.account_balances: dict[str, object] = {} + response: requests.Response = self.session._request("get", url=urls.user_info()) + self.user_info: dict[str, object] = response.json() + response: requests.Response = self.session._request("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: str) -> dict[str, object]: + """Get account balances for a given account. Args: - session (requests.Session): - The session object used for making HTTP requests. + account (str): Account number of the account you want to get balances for. + + Returns: + dict: Dict of the response from the API. + """ - 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 - - def get_positions(self, account): - """Gets currently held positions for a given account. + response: requests.Response = self.session._request("get", urls.account_balances(account)) + return response.json() + + def get_positions(self, account: str) -> dict[str, object]: + """Get currently held positions for a given account. Args: 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._request("get", urls.account_positions(account)) + return response.json() + + def get_account_history( + self, + account: str, + date_range: str = "ytd", + custom_range: list[str] | None = None, + ) -> dict[str, object]: + """Get account history for a given account. + + Args: + account (str): Account number of the account you want to get history for. + date_range (str): The range of the history. Defaults to "ytd". + Available options are + ["today", "1w", "1m", "2m", "mtd", "ytd", "ly", "cust"]. + custom_range (list[str] | None): 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 required.") + response: requests.Response = self.session._request( + "get", + urls.account_history(account, date_range, custom_range), + ) + return response.json() + + def get_orders(self, account: str) -> list[dict[str, object]]: + """Retrieve existing order data for a given account. + + Args: + 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._request("get", url=urls.order_list(account)) + return response.json() + + def cancel_order(self, order_id: str) -> dict[str, object]: + """Cancel 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._request("post", url=urls.cancel_order(), data=data) + return response.json() + + def get_balance_overview(self, account: str, keywords: list[str] | None = None) -> dict[str, object]: + """Return 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: dict[str, object] = self.get_account_balances(account) + + filtered: dict[str, object] = {} + + def _walk(node: object, path: list[str]) -> None: + if isinstance(node, dict): + for k, v in node.items(): + _walk(node=v, path=[*path, str(object=k)]) + elif isinstance(node, list): + for i, v in enumerate(iterable=node): + _walk(node=v, path=[*path, str(object=i)]) + else: + key_path: str = ".".join(path) + low: str = key_path.lower() + if any(sub in low for sub in keywords): + filtered[key_path] = node + + _walk(node=payload, path=[]) + return filtered diff --git a/firstrade/exceptions.py b/firstrade/exceptions.py new file mode 100644 index 0000000..4e31cc8 --- /dev/null +++ b/firstrade/exceptions.py @@ -0,0 +1,54 @@ +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: str) -> None: + """Raise error for login response issues.""" + 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..dd334fd 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -1,15 +1,20 @@ -from enum import Enum - -from bs4 import BeautifulSoup +import enum 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. +class PriceType(enum.StrEnum): + """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" @@ -20,208 +25,216 @@ class PriceType(str, Enum): TRAILING_STOP_PERCENT = "6" -class Duration(str, Enum): - """ - This is an :class:'~enum.Enum' - that contains the valid durations for an order. +class Duration(enum.StrEnum): + """Enum for valid order durations. + + Attributes: + DAY (str): Day order (9:30 AM - 4 PM ET) + DAY_EXT (str): Day extended order (8 AM - 8 PM ET). + OVERNIGHT (str): Overnight order (8 PM - 4 AM ET). + GT90 (str): Good till 90 days order (9:30 AM - 4 PM ET). + """ DAY = "0" - GT90 = "1" - PRE_MARKET = "A" - AFTER_MARKET = "P" DAY_EXT = "D" + OVERNIGHT = "N" + GT90 = "1" -class OrderType(str, Enum): - """ - This is an :class:'~enum.Enum' - that contains the valid order types for an order. +class OrderType(enum.StrEnum): + """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 Order: +class OrderInstructions(enum.StrEnum): + """Enum for valid order instructions. + + Attributes: + NONE (str): No special instruction. + AON (str): All or none. + OPG (str): At the Open. + CLO (str): At the Close. + + """ + + NONE = "0" + AON = "1" + OPG = "4" + CLO = "5" + + +class OptionType(enum.StrEnum): + """Enum for valid option types. + + Attributes: + CALL (str): Call option. + PUT (str): Put option. + """ - This class contains information about an order. - It also contains a method to place an order. + + CALL = "C" + PUT = "P" + + +class 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 __init__(self, ft_session: FTSession) -> None: + """Initialize the Order with a FirstTrade session.""" + self.ft_session: FTSession = ft_session 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 = None, + *, + dry_run: bool = True, + notional: bool = False, + order_instruction: OrderInstructions = OrderInstructions.NONE, ): - """ - Builds and places an order. - :attr: 'order_confirmation` - contains the order confirmation data after order placement. + """Build and place an order. 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". Returns: - Order:order_confirmation: Dictionary containing the order confirmation data. - """ - - if dry_run: - previewOrders = "1" - else: - previewOrders = "" + dict: A dictionary containing the order confirmation data. - 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: requests.Response = self.ft_session._request("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._request("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("