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: -[![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..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("") + 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 = None, + price: float = 0.00, + *, + dry_run: bool = True, + order_instruction: OrderInstructions = OrderInstructions.NONE, + ): + """Build and place an option 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. + 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. - Returns: - list: A list of dictionaries, each containing details about an order. - """ + Returns: + dict: A dictionary containing the order confirmation data. - # 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 + """ + 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 = { + "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._request("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._request("post", url=urls.option_order(), data=data) + return response.json() diff --git a/firstrade/symbols.py b/firstrade/symbols.py index 2df0d00..2131e36 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -1,61 +1,250 @@ -from bs4 import BeautifulSoup +from typing import Any 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): + """Initialize 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. + """ - Initializes a new instance of the SymbolQuote class. + self.ft_session: FTSession = ft_session + response = self.ft_session._request("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: str = response.json()["result"]["symbol"] + self.sec_type: str = response.json()["result"]["sec_type"] + self.tick: str = response.json()["result"]["tick"] + self.bid: str = response.json()["result"]["bid"] + self.bid_size: str = response.json()["result"]["bid_size"] + self.ask: str = response.json()["result"]["ask"] + self.ask_size: str = response.json()["result"]["ask_size"] + self.last: str = response.json()["result"]["last"] + self.change: str = response.json()["result"]["change"] + self.high: str = response.json()["result"]["high"] + self.low: str = response.json()["result"]["low"] + self.bid_mmid: str = response.json()["result"]["bid_mmid"] + self.ask_mmid: str = response.json()["result"]["ask_mmid"] + self.last_mmid: str = response.json()["result"]["last_mmid"] + self.last_size: int = response.json()["result"]["last_size"] + self.change_color: str = response.json()["result"]["change_color"] + self.volume: str = response.json()["result"]["vol"] + self.today_close: float = response.json()["result"]["today_close"] + self.open: str = response.json()["result"]["open"] + self.quote_time: str = response.json()["result"]["quote_time"] + self.last_trade_time: str = response.json()["result"]["last_trade_time"] + self.company_name: str = response.json()["result"]["company_name"] + self.exchange: str = response.json()["result"]["exchange"] + self.has_option: str = response.json()["result"]["has_option"] + self.is_etf: bool = bool(response.json()["result"]["is_etf"]) + self.is_fractional = bool(response.json()["result"]["is_fractional"]) + self.realtime: str = response.json()["result"]["realtime"] + self.nls: str = response.json()["result"]["nls"] + self.shares: str = 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): + """Initialize 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() + self.option_dates = self.get_option_dates(symbol) + + def get_option_dates(self, symbol: str): + """Retrieve 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. + + """ + response = self.ft_session._request("get", url=urls.option_dates(symbol)) + return response.json() + + def get_option_quote(self, symbol: str, date: str) -> dict[Any, Any]: + """Retrieve 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. + + """ + response = self.ft_session._request("get", url=urls.option_quotes(symbol, date)) + return response.json() + + def get_greek_options(self, symbol: str, exp_date: str): + """Retrieve 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. + + """ + data = { + "type": "chain", + "chains_range": "A", + "root_symbol": symbol, + "exp_date": exp_date, + } + response = self.ft_session._request("post", url=urls.greek_options(), data=data) + return response.json() + + +class SymbolOHLC: + """Data class representing OHLC (Open, High, Low, Close) price data for a given symbol. + + Attributes: + ft_session (FTSession): The session object used for making HTTP requests + to Firstrade. + symbol (str): The trading symbol for which OHLC data is retrieved. + range (str): The time range for the OHLC data. + start_of_day (int, optional): Unix timestamp in milliseconds representing the + start of the OHLC data. + ohlc_raw (list): Raw OHLC data returned by the API. + vol_raw (list): Raw volume data returned by the API. + candles (list): A list of parsed OHLC candles in the format: + (timestamp_ms, open, high, low, close, volume). + + """ + + def __init__(self, ft_session: FTSession, symbol: str, range_: str = "1d"): + """Initialize a new instance of the SymbolOHLC class. + + Args: + ft_session (FTSession): The session object used for making HTTP + requests to Firstrade. + symbol (str): The symbol for which OHLC data is retrieved. + range_ (str, optional): The time range for the OHLC data (24h, 1d, 1w, 1m, 1y). + + Raises: + QuoteRequestError: If the OHLC request fails with a non-200 + status code. + QuoteResponseError: If the OHLC response contains an error + message. + + """ + self.ft_session = ft_session + self.symbol: str = symbol + self.range: str = range_ + + response = self.ft_session._request( + method="get", + url=urls.ohlc(symbol, range_), ) - 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" + + if response.status_code != 200: + raise QuoteRequestError(response.status_code) + + data = response.json() + if data.get("error", ""): + raise QuoteResponseError(symbol, data["error"]) + + result = data["result"] + + self.start_of_day: Optional[int] = result.get("startOfDay") + self.ohlc_raw: list = result["ohlc"] + self.vol_raw: list = result.get("vol", []) + + self.candles: List[Tuple[int, float, float, float, float, int]] = [] + + self._parse_ohlc_and_volume() + + def _parse_ohlc_and_volume(self) -> None: + """Parse OHLC and volume data returned by the API. + + The API provides OHLC candles and volume as separate arrays, + each keyed by the same millisecond timestamp. + + This method aligns volume with its corresponding candle and + populates the `candles` attribute. + """ + volume_map: Dict[int, int] = {ts: vol for ts, vol in self.vol_raw} + + for entry in self.ohlc_raw: + # OHLC may be [ts, o, h, l, c] or [ts, o, h, l, c, vol] + timestamp = entry[0] + open_, high, low, close = entry[1:5] + + # Prefer volume from vol[]; fall back to embedded volume if present + if timestamp in volume_map: + volume = volume_map[timestamp] + elif len(entry) == 6: + volume = entry[5] + else: + raise KeyError(f"Missing volume for timestamp {timestamp}") + + self.candles.append( + (timestamp, open_, high, low, close, volume), + ) diff --git a/firstrade/urls.py b/firstrade/urls.py index 3b5bac8..98f1404 100644 --- a/firstrade/urls.py +++ b/firstrade/urls.py @@ -1,46 +1,104 @@ -def get_xml(): - return "https://invest.firstrade.com/cgi-bin/getxml" +def login() -> str: + """Login URL for FirstTrade API.""" + return "https://api3x.firstrade.com/sess/login" -def login(): - return "https://invest.firstrade.com/cgi-bin/login" +def request_code() -> str: + """Request PIN/MFA option for FirstTrade API.""" + return "https://api3x.firstrade.com/sess/request_code" -def pin(): - return "https://invest.firstrade.com/cgi-bin/enter_pin?destination_page=home" +def verify_pin() -> str: + """Request PIN/MFA verification for FirstTrade API.""" + return "https://api3x.firstrade.com/sess/verify_pin" -def account_list(): - return "https://invest.firstrade.com/cgi-bin/getaccountlist" +def user_info() -> str: + """Retrieve user information URL for FirstTrade API.""" + return "https://api3x.firstrade.com/private/userinfo" -def quote(symbol): - return f"https://invest.firstrade.com/cgi-bin/getxml?page=quo"eSymbol={symbol}" +def account_list() -> str: + """Retrieve account list URL for FirstTrade API.""" + return "https://api3x.firstrade.com/private/acct_list" -def orderbar(): - return "https://invest.firstrade.com/cgi-bin/orderbar" +def account_balances(account: str) -> str: + """Retrieve account balances URL for FirstTrade API.""" + return f"https://api3x.firstrade.com/private/balances?account={account}" -def account_status(): - return "https://invest.firstrade.com/cgi-bin/account_status" +def account_positions(account: str) -> str: + """Retrieve account positions URL for FirstTrade API.""" + return f"https://api3x.firstrade.com/private/positions?account={account}&per_page=200" -def order_list(): - return "https://invest.firstrade.com/cgi-bin/orderstatus" +def quote(account: str, symbol: str) -> str: + """Symbol quote URL for FirstTrade API.""" + return f"https://api3x.firstrade.com/public/quote?account={account}&q={symbol}" -def status(): - return "https://invest.firstrade.com/scripts/profile/margin_v2.php" +def ohlc(symbol: str, range_: str) -> str: + """Open-high-low-close chart data URL for FirstTrade API.""" + return ( + "https://api3x.firstrade.com/public/ohlc" + f"?symbol={symbol}&range={range_}&_v=v2" + ) -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", + +def order() -> str: + """Place equity order URL for FirstTrade API.""" + return "https://api3x.firstrade.com/private/stock_order" + + +def order_list(account: str) -> str: + """Retrieve placed order list URL for FirstTrade API.""" + return f"https://api3x.firstrade.com/private/order_status?account={account}" + + +def account_history(account: str, date_range: str, custom_range: list[str] | None) -> str: + """Retrieve account history URL for FirstTrade API.""" + 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 cancel_order() -> str: + """Cancel placed order URL for FirstTrade API.""" + return "https://api3x.firstrade.com/private/cancel_order" + + +def option_dates(symbol: str) -> str: + """Option dates URL for FirstTrade API.""" + return f"https://api3x.firstrade.com/public/oc?m=get_exp_dates&root_symbol={symbol}" + + +def option_quotes(symbol: str, date: str) -> str: + """Option quotes URL for FirstTrade API.""" + return f"https://api3x.firstrade.com/public/oc?m=get_oc&root_symbol={symbol}&exp_date={date}&chains_range=A" + + +def greek_options() -> str: + """Greek options analytical data URL for FirstTrade API.""" + return "https://api3x.firstrade.com/private/greekoptions/analytical" + + +def option_order() -> str: + """Place option order URL for FirstTrade API.""" + return "https://api3x.firstrade.com/private/option_order" + + +def session_headers() -> dict[str, str]: + """Session headers for FirstTrade API.""" + headers: dict[str, str] = { + "Accept-Encoding": "gzip", + "Connection": "Keep-Alive", + "Host": "api3x.firstrade.com", + "User-Agent": "okhttp/4.9.2", } return headers + + +def access_token() -> str: + """Access token for FirstTrade API.""" + return "833w3XuIFycv18ybi" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0224cbe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[tool.ruff] +target-version = "py312" +extend = "./styles/ruff.toml" +include = ["src/*"] +exclude = ["src/vendors/*", "guides/*"] + +[tool.ruff.lint] +ignore = [ + "BLE001", # Allow blind exception catch + "T201", # Allow prints + "TRY002", # Create own exception + "TRY301", # Allow raising exceptions + "DOC201", # Don't require returns in doc + "DOC501", # Don't require raises in doc + "PLR0913", # Allow too many arguments + "PLR1702", # Allow many nested blocks +] diff --git a/setup.py b/setup.py index 6b0273b..84c9763 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,12 @@ +import pathlib + import setuptools -with open("README.md", "r") as f: - long_description = f.read() +long_description = pathlib.Path("README.md").read_text() setuptools.setup( name="firstrade", - version="0.0.17", + version="0.0.38", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,9 +14,9 @@ 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/0038.tar.gz", keywords=["FIRSTRADE", "API"], - install_requires=["requests", "beautifulsoup4", "lxml"], + install_requires=["requests", "pyotp"], packages=["firstrade"], classifiers=[ "Development Status :: 3 - Alpha", @@ -23,9 +24,9 @@ "Topic :: Internet :: WWW/HTTP :: Session", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "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/styles/ruff.toml b/styles/ruff.toml new file mode 100644 index 0000000..6551d68 --- /dev/null +++ b/styles/ruff.toml @@ -0,0 +1,15 @@ +line-length = 320 # I don't like text wrapping +indent-width = 4 +preview = true + +[format] +line-ending = "lf" + +[lint] +select = ["ALL"] +ignore = [ + "CPY001", # Ignore license header checks + "D100", # Ignore missing doc strings in modules + "D203", # Conflicts with D211 (we like no blank line before class docstring) + "D213", # Conflicts with D212 (we like first docstring line at top) +] diff --git a/test.py b/test.py old mode 100644 new mode 100755 index 2718dd9..ddf5f6f --- a/test.py +++ b/test.py @@ -1,8 +1,19 @@ +#!/usr/bin/python3 + +import json + from firstrade import account, order, symbols -from firstrade.order import get_orders # Create a session -ft_ss = account.FTSession(username="", password="", pin="") +# mfa_secret is the secret key to generate TOTP (not the backup code), see: +# https://help.firstrade.info/en/articles/9260184-two-factor-authentication-2fa +# save session flag now required to save cookies json file +ft_ss = account.FTSession(username="", password="", mfa_secret="", save_session=True) +# 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) @@ -10,64 +21,144 @@ raise Exception("No accounts found or an error occured exiting...") # Print ALL account data -print(ft_accounts.all_accounts) +print(f"Account data: {json.dumps(ft_accounts.all_accounts, indent=2)}") # Print 1st account number. -print(ft_accounts.account_numbers[0]) +print(f"1st account number: {ft_accounts.account_numbers[0]}") # Print ALL accounts market values. -print(ft_accounts.account_balances) +print(f"Account(s) current balance(s): {ft_accounts.account_balances}") # Get quote for INTC -quote = symbols.SymbolQuote(ft_ss, "INTC") -print(f"Symbol: {quote.symbol}") -print(f"Exchange: {quote.exchange}") -print(f"Bid: {quote.bid}") -print(f"Ask: {quote.ask}") -print(f"Last: {quote.last}") -print(f"Change: {quote.change}") -print(f"High: {quote.high}") -print(f"Low: {quote.low}") -print(f"Volume: {quote.volume}") -print(f"Company Name: {quote.company_name}") +quote = symbols.SymbolQuote(ft_ss, ft_accounts.account_numbers[0], "INTC") +print("Quote for INTC:") +print(f"\tSymbol: {quote.symbol}") +print(f"\tTick: {quote.tick}") +print(f"\tExchange: {quote.exchange}") +print(f"\tBid: {quote.bid}") +print(f"\tAsk: {quote.ask}") +print(f"\tLast: {quote.last}") +print(f"\tBid Size: {quote.bid_size}") +print(f"\tAsk Size: {quote.ask_size}") +print(f"\tLast Size: {quote.last_size}") +print(f"\tBid MMID: {quote.bid_mmid}") +print(f"\tAsk MMID: {quote.ask_mmid}") +print(f"\tLast MMID: {quote.last_mmid}") +print(f"\tChange: {quote.change}") +print(f"\tHigh: {quote.high}") +print(f"\tLow: {quote.low}") +print(f"\tChange Color: {quote.change_color}") +print(f"\tVolume: {quote.volume}") +print(f"\tQuote Time: {quote.quote_time}") +print(f"\tLast Trade Time: {quote.last_trade_time}") +print(f"\tReal Time: {quote.realtime}") +print(f"\tFractional: {quote.is_fractional}") +print(f"\tCompany 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( - f"Quantity {ft_accounts.securities_held[key]['quantity']} of security {key} held in account {ft_accounts.account_numbers[1]}" - ) +positions = ft_accounts.get_positions(account=ft_accounts.account_numbers[0]) +print(f"Current positions held in account {ft_accounts.account_numbers[0]}: {json.dumps(positions, indent=2)}") +print(f"Current positions (summed up) held in account {ft_accounts.account_numbers[0]}:") +for item in positions["items"]: + print(f"\t{item['quantity']}\tof security {item['symbol']}.") + +# Get account history for a custom date range +history = ft_accounts.get_account_history( + account=ft_accounts.account_numbers[0], + date_range="cust", + custom_range=["2025-12-01", "2025-12-31"], +) + +print(f"Transaction history (December 2025) for account #{ft_accounts.account_numbers[0]}: {json.dumps(history, indent=2)}") +if len(history["items"]) > 0: + print("Transaction history (summed up) for December 2025:") + for item in history["items"]: + print(f"\t{item['report_date']}: {item['amount']}$\tof {item['symbol']}") # Create an order object. ft_order = order.Order(ft_ss) -# Place order and print out order confirmation data. -ft_order.place_order( +# Place a 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, + price=3.37, dry_run=True, ) +print(f"Preview of an order to buy 1 share of INTC: {json.dumps(order_conf, indent=2)}") -# Print Order data Dict -print(ft_order.order_confirmation) - -# 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_conf.get("error"): + print(f"Error placing order: {order_conf['error']} : {order_conf['message']}") +elif "order_id" not in order_conf["result"]: + print("Dry run complete!") else: - print("Failed to place order.") - # Print errormessage - print(ft_order.order_confirmation["actiondata"]) + print("Order placed successfully!") + print(f"\tOrder ID: {order_conf['result']['order_id']}.") + print(f"\tOrder State: {order_conf['result']['state']}.") + +# Cancel placed order (on success and if it was not a dry_run) +if not order_conf.get("error") and "order_id" in order_conf["result"]: + cancel = ft_accounts.cancel_order(order_conf["result"]["order_id"]) + if cancel["result"]["result"] == "success": + print(f"Order cancelled successfully: {cancel}.") + else: + print(f"Cannot cancel order: {cancel}.") + + +# Retrieve OHLC data +ohlc = symbols.SymbolOHLC(ft_ss, "INTC", range_="1y") +print(f"Open-high-low-close chart data for INTC (first two values, format: ): {ohlc.candles[:2]}") # 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(f"Recent orders: {json.dumps(recent_orders, indent=2)}") + +# Get option dates for a symbol +option_first = symbols.OptionQuote(ft_ss, "INTC") +print("Option expiration dates for INTC:") +for item in option_first.option_dates["items"]: + print( + f"\tExpiration 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"], +) +limited_option_quote = { + **option_quote, + "items": option_quote["items"][:2], +} +print(f"Option quote for INTC (limited to the first two items): {json.dumps(limited_option_quote, indent=2)}") + +# Get option greeks +option_greeks = option_first.get_greek_options( + "INTC", + option_first.option_dates["items"][0]["exp_date"], +) +limited_option_greeks = { + **option_greeks, + "chains": option_greeks["chains"][:2], +} +print(f"Option greeks at {option_first.option_dates['items'][0]['exp_date']} for INTC (limited to the first two chains): {json.dumps(limited_option_greeks, indent=2)}") + +# 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.LIMIT, + duration=order.Duration.DAY, + price=0.01, + contracts=1, + dry_run=True, +) +print(f"Preview of an option order for {option_quote['items'][0]['opt_symbol']}: {json.dumps(option_order, indent=2)}") -# Delete cookies -ft_ss.delete_cookies() \ No newline at end of file +# Delete the session cookie +# ft_ss.delete_cookies()