diff --git a/.gitignore b/.gitignore index 304d7d4..9d45d97 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ *$py.class testme.py *.pkl +_*.py # C extensions *.so diff --git a/README.md b/README.md index 613e8eb..1cf0f00 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,10 @@ In order to use Fractional shares you must accept the agreement on the website b ## Contribution -I am new to coding and new to open-source. I would love any help and suggestions! +Please feel free to contribute to this project. If you find any bugs, please open an issue. + +## Disclaimer +I am not a financial advisor and not affiliated with Firstrade in any way. Use this tool at your own risk. I am not responsible for any losses or damages you may incur by using this project. This tool is provided as-is with no warranty. ## Setup @@ -24,33 +27,41 @@ pip install firstrade ## Quikstart -`Checkout test.py for sample code.` - -This code will: +The code in `test.py` will: - Login and print account info. - Get a quote for 'INTC' and print out the information -- Place a market order for 'INTC' on the first account in the `account_numbers` list +- Place a dry run market order for 'INTC' on the first account in the `account_numbers` list - Print out the order confirmation - -`Checkout test.py for sample code.` - +- Contains a cancel order example +- Get an option Dates, Quotes, and Greeks +- Place a dry run option order --- ## Implemented Features -- [x] Login +- [x] Login (With all 2FA methods now supported!) - [x] Get Quotes - [x] Get Account Data - [x] Place Orders and Receive order confirmation - [x] Get Currently Held Positions - [x] Fractional Trading support (thanks to @jiak94) - [x] Check on placed order status. (thanks to @Cfomodz) +- [x] Cancel placed orders +- [x] Options (Orders, Quotes, Greeks) +- [x] Order History ## TO DO -- [ ] Cancel placed orders -- [ ] Options +- [ ] Test options fully - [ ] Give me some Ideas! +## Options + +### I am very new to options trading and have not fully tested this feature. + +Please: +- USE THIS FEATURE LIKE IT IS A ALPHA/BETA +- PUT IN A GITHUB ISSUE IF YOU FIND ANY PROBLEMS + ## If you would like to support me, you can do so here: -[](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..fd9bb96 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -1,17 +1,67 @@ +import json import os import pickle -import re +import pyotp import requests -from bs4 import BeautifulSoup from firstrade import urls +from firstrade.exceptions import ( + AccountResponseError, + LoginRequestError, + LoginResponseError, +) class FTSession: - """Class creating a session for Firstrade.""" + """ + Class creating a session for Firstrade. - def __init__(self, username, password, pin, profile_path=None): + This class handles the creation and management of a session for logging into the Firstrade platform. + It supports multi-factor authentication (MFA) and can save session cookies for persistent logins. + + Attributes: + username (str): Firstrade login username. + password (str): Firstrade login password. + pin (str): Firstrade login pin. + email (str, optional): Firstrade MFA email. + phone (str, optional): Firstrade MFA phone number. + mfa_secret (str, optional): Secret key for generating MFA codes. + profile_path (str, optional): The path where the user wants to save the cookie pkl file. + t_token (str, optional): Token used for MFA. + otp_options (dict, optional): Options for OTP (One-Time Password) if MFA is enabled. + login_json (dict, optional): JSON response from the login request. + session (requests.Session): The requests session object used for making HTTP requests. + + Methods: + __init__(username, password, pin=None, email=None, phone=None, mfa_secret=None, profile_path=None): + Initializes a new instance of the FTSession class. + login(): + Validates and logs into the Firstrade platform. + login_two(code): + Finishes the login process to the Firstrade platform. When using email or phone mfa. + delete_cookies(): + Deletes the session cookies. + _load_cookies(): + Checks if session cookies were saved and loads them. + _save_cookies(): + Saves session cookies to a file. + _mask_email(email): + Masks the email for use in the API. + _handle_mfa(): + Handles multi-factor authentication. + """ + + def __init__( + self, + username, + password, + pin=None, + email=None, + phone=None, + mfa_secret=None, + profile_path=None, + ): """ Initializes a new instance of the FTSession class. @@ -19,76 +69,113 @@ def __init__(self, username, password, pin, profile_path=None): username (str): Firstrade login username. password (str): Firstrade login password. pin (str): Firstrade login pin. - persistent_session (bool, optional): Whether the user wants to save the session cookies. + email (str, optional): Firstrade MFA email. + phone (str, optional): Firstrade MFA phone number. profile_path (str, optional): The path where the user wants to save the cookie pkl file. """ self.username = username self.password = password self.pin = pin + self.email = FTSession._mask_email(email) if email is not None else None + self.phone = phone + self.mfa_secret = mfa_secret self.profile_path = profile_path + self.t_token = None + self.otp_options = None + self.login_json = None self.session = requests.Session() - self.login() def login(self): - """Method to validate and login to the Firstrade platform.""" - headers = urls.session_headers() - cookies = self.load_cookies() - cookies = requests.utils.cookiejar_from_dict(cookies) - self.session.cookies.update(cookies) - response = self.session.get( - url=urls.get_xml(), headers=urls.session_headers(), cookies=cookies - ) - if response.status_code != 200: - raise Exception( - "Login failed. Check your credentials or internet connection." - ) - if "/cgi-bin/sessionfailed?reason=6" in response.text: - self.session.get(url=urls.login(), headers=headers) - data = { - "redirect": "", - "ft_locale": "en-us", - "login.x": "Log In", - "username": r"" + self.username, - "password": r"" + self.password, - "destination_page": "home", - } + """ + Validates and logs into the Firstrade platform. - self.session.post( - url=urls.login(), - headers=headers, - cookies=self.session.cookies, - data=data, - ) - data = { - "destination_page": "home", - "pin": self.pin, - "pin.x": "++OK++", - "sring": "0", - "pin": self.pin, - } + This method sets up the session headers, loads cookies if available, and performs the login request. + It handles multi-factor authentication (MFA) if required. - self.session.post( - url=urls.pin(), headers=headers, cookies=self.session.cookies, data=data - ) - self.save_cookies() + Raises: + LoginRequestError: If the login request fails with a non-200 status code. + LoginResponseError: If the login response contains an error message. + """ + self.session.headers = urls.session_headers() + ftat = self._load_cookies() + if ftat != "": + self.session.headers["ftat"] = ftat + response = self.session.get(url="https://api3x.firstrade.com/", timeout=10) + self.session.headers["access-token"] = urls.access_token() + + data = { + "username": r"" + self.username, + "password": r"" + self.password, + } + + response = self.session.post( + url=urls.login(), + data=data, + ) + try: + self.login_json = response.json() + except json.decoder.JSONDecodeError: + raise LoginResponseError("Invalid JSON is your account funded?") if ( - "/cgi-bin/sessionfailed?reason=6" - in self.session.get( - url=urls.get_xml(), headers=urls.session_headers(), cookies=cookies - ).text + "mfa" not in self.login_json + and "ftat" in self.login_json + and self.login_json["error"] == "" ): - raise Exception("Login failed. Check your credentials.") + self.session.headers["sid"] = self.login_json["sid"] + return False + self.t_token = self.login_json.get("t_token") + if self.mfa_secret is None: + self.otp_options = self.login_json.get("otp") + if response.status_code != 200: + raise LoginRequestError(response.status_code) + if self.login_json["error"] != "": + raise LoginResponseError(self.login_json["error"]) + need_code = self._handle_mfa() + if self.login_json["error"] != "": + raise LoginResponseError(self.login_json["error"]) + if need_code: + return True + self.session.headers["ftat"] = self.login_json["ftat"] + self.session.headers["sid"] = self.login_json["sid"] + self._save_cookies() + return False - def load_cookies(self): + def login_two(self, code): + """Method to finish login to the Firstrade platform.""" + data = { + "otpCode": code, + "verificationSid": self.session.headers["sid"], + "remember_for": "30", + "t_token": self.t_token, + } + response = self.session.post(urls.verify_pin(), data=data) + self.login_json = response.json() + if self.login_json["error"] != "": + raise LoginResponseError(self.login_json["error"]) + self.session.headers["ftat"] = self.login_json["ftat"] + self.session.headers["sid"] = self.login_json["sid"] + self._save_cookies() + + def delete_cookies(self): + """Deletes the session cookies.""" + if self.profile_path is not None: + path = os.path.join(self.profile_path, f"ft_cookies{self.username}.pkl") + else: + path = f"ft_cookies{self.username}.pkl" + os.remove(path) + + def _load_cookies(self): """ Checks if session cookies were saved. Returns: - Dict: Dictionary of cookies. Nom Nom + str: The saved session token. """ - cookies = {} - directory = os.path.abspath(self.profile_path) if self.profile_path is not None else "." - + + ftat = "" + directory = ( + os.path.abspath(self.profile_path) if self.profile_path is not None else "." + ) if not os.path.exists(directory): os.makedirs(directory) @@ -96,10 +183,10 @@ def load_cookies(self): if filename.endswith(f"{self.username}.pkl"): filepath = os.path.join(directory, filename) with open(filepath, "rb") as f: - cookies = pickle.load(f) - return cookies + ftat = pickle.load(f) + return ftat - def save_cookies(self): + def _save_cookies(self): """Saves session cookies to a file.""" if self.profile_path is not None: directory = os.path.abspath(self.profile_path) @@ -109,15 +196,78 @@ def save_cookies(self): else: path = f"ft_cookies{self.username}.pkl" with open(path, "wb") as f: - pickle.dump(self.session.cookies.get_dict(), f) - - def delete_cookies(self): - """Deletes the session cookies.""" - if self.profile_path is not None: - path = os.path.join(self.profile_path, f"ft_cookies{self.username}.pkl") - else: - path = f"ft_cookies{self.username}.pkl" - os.remove(path) + ftat = self.session.headers.get("ftat") + pickle.dump(ftat, f) + + @staticmethod + def _mask_email(email): + """ + Masks the email for use in the API. + + Args: + email (str): The email address to be masked. + + Returns: + str: The masked email address. + """ + local, domain = email.split("@") + masked_local = local[0] + "*" * 4 + domain_name, tld = domain.split(".") + masked_domain = domain_name[0] + "*" * 4 + return f"{masked_local}@{masked_domain}.{tld}" + + def _handle_mfa(self): + """ + Handles multi-factor authentication. + + This method processes the MFA requirements based on the login response and user-provided details. + + Raises: + LoginRequestError: If the MFA request fails with a non-200 status code. + LoginResponseError: If the MFA response contains an error message. + """ + if not self.login_json["mfa"] and self.pin is not None: + data = { + "pin": self.pin, + "remember_for": "30", + "t_token": self.t_token, + } + response = self.session.post(urls.verify_pin(), data=data) + self.login_json = response.json() + elif not self.login_json["mfa"] and ( + self.email is not None or self.phone is not None + ): + for item in self.otp_options: + if item["channel"] == "sms" and self.phone is not None: + if self.phone in item["recipientMask"]: + data = { + "recipientId": item["recipientId"], + "t_token": self.t_token, + } + break + elif item["channel"] == "email" and self.email is not None: + if self.email == item["recipientMask"]: + data = { + "recipientId": item["recipientId"], + "t_token": self.t_token, + } + break + response = self.session.post(urls.request_code(), data=data) + elif self.login_json["mfa"] and self.mfa_secret is not None: + mfa_otp = pyotp.TOTP(self.mfa_secret).now() + data = { + "mfaCode": mfa_otp, + "remember_for": "30", + "t_token": self.t_token, + } + response = self.session.post(urls.verify_pin(), data=data) + self.login_json = response.json() + if self.login_json["error"] == "": + if self.pin or self.mfa_secret is not None: + self.session.headers["sid"] = self.login_json["sid"] + return False + self.session.headers["sid"] = self.login_json["verificationSid"] + return True def __getattr__(self, name): """ @@ -146,74 +296,28 @@ def __init__(self, session): self.session = session self.all_accounts = [] self.account_numbers = [] - self.account_statuses = [] - self.account_balances = [] - self.securities_held = {} - all_account_info = [] - html_string = self.session.get( - url=urls.account_list(), - headers=urls.session_headers(), - cookies=self.session.cookies, - ).text - regex_accounts = re.findall(r"([0-9]+)-", html_string) - - for match in regex_accounts: - self.account_numbers.append(match) - - for account in self.account_numbers: - # reset cookies to base login cookies to run scripts - self.session.cookies.clear() - self.session.cookies.update(self.session.load_cookies()) - # set account to get data for - data = {"accountId": account} - self.session.post( - url=urls.account_status(), - headers=urls.session_headers(), - cookies=self.session.cookies, - data=data, - ) - # request to get account status data - data = {"req": "get_status"} - account_status = self.session.post( - url=urls.status(), - headers=urls.session_headers(), - cookies=self.session.cookies, - data=data, - ).json() - self.account_statuses.append(account_status["data"]) - data = {"page": "bal", "account_id": account} - account_soup = BeautifulSoup( - self.session.post( - url=urls.get_xml(), - headers=urls.session_headers(), - cookies=self.session.cookies, - data=data, - ).text, - "xml", - ) - balance = account_soup.find("total_account_value").text - self.account_balances.append(balance) - all_account_info.append( - { - account: { - "Balance": balance, - "Status": { - "primary": account_status["data"]["primary"], - "domestic": account_status["data"]["domestic"], - "joint": account_status["data"]["joint"], - "ira": account_status["data"]["ira"], - "hasMargin": account_status["data"]["hasMargin"], - "opLevel": account_status["data"]["opLevel"], - "p_country": account_status["data"]["p_country"], - "mrgnStatus": account_status["data"]["mrgnStatus"], - "opStatus": account_status["data"]["opStatus"], - "margin_id": account_status["data"]["margin_id"], - }, - } - } - ) - - self.all_accounts = all_account_info + self.account_balances = {} + response = self.session.get(url=urls.user_info()) + self.user_info = response.json() + response = self.session.get(urls.account_list()) + if response.status_code != 200 or response.json()["error"] != "": + raise AccountResponseError(response.json()["error"]) + self.all_accounts = response.json() + for item in self.all_accounts["items"]: + self.account_numbers.append(item["account"]) + self.account_balances[item["account"]] = item["total_value"] + + def get_account_balances(self, account): + """Gets account balances for a given account. + + Args: + account (str): Account number of the account you want to get balances for. + + Returns: + dict: Dict of the response from the API. + """ + response = self.session.get(urls.account_balances(account)) + return response.json() def get_positions(self, account): """Gets currently held positions for a given account. @@ -222,36 +326,113 @@ def get_positions(self, account): account (str): Account number of the account you want to get positions for. Returns: - self.securities_held {dict}: - Dict of held positions with the pos. ticker as the key. + dict: Dict of the response from the API. + """ + + response = self.session.get(urls.account_positions(account)) + return response.json() + + def get_account_history(self, account, date_range="ytd", custom_range=None): + """Gets account history for a given account. + + Args: + account (str): Account number of the account you want to get history for. + range (str): The range of the history. Defaults to "ytd". + Available options are + ["today", "1w", "1m", "2m", "mtd", "ytd", "ly", "cust"]. + custom_range (str): The custom range of the history. + Defaults to None. If range is "cust", + this parameter is required. + Format: ["YYYY-MM-DD", "YYYY-MM-DD"]. + + Returns: + dict: Dict of the response from the API. + """ + if date_range == "cust" and custom_range is None: + raise ValueError("Custom range is required when date_range is 'cust'.") + response = self.session.get( + urls.account_history(account, date_range, custom_range) + ) + return response.json() + + def get_orders(self, account): + """ + Retrieves existing order data for a given account. + + Args: + ft_session (FTSession): The session object used for making HTTP requests to Firstrade. + account (str): Account number of the account to retrieve orders for. + + Returns: + list: A list of dictionaries, each containing details about an order. """ + + response = self.session.get(url=urls.order_list(account)) + return response.json() + + def cancel_order(self, order_id): + """ + Cancels an existing order. + + Args: + order_id (str): The order ID to cancel. + + Returns: + dict: A dictionary containing the response data. + """ + data = { - "page": "pos", - "accountId": str(account), + "order_id": order_id, } - position_soup = BeautifulSoup( - self.session.post( - url=urls.get_xml(), - headers=urls.session_headers(), - data=data, - cookies=self.session.cookies, - ).text, - "xml", - ) - tickers = position_soup.find_all("symbol") - quantity = position_soup.find_all("quantity") - price = position_soup.find_all("price") - change = position_soup.find_all("change") - change_percent = position_soup.find_all("changepercent") - vol = position_soup.find_all("vol") - for i, ticker in enumerate(tickers): - ticker = ticker.text - self.securities_held[ticker] = { - "quantity": quantity[i].text, - "price": price[i].text, - "change": change[i].text, - "change_percent": change_percent[i].text, - "vol": vol[i].text, - } - return self.securities_held + response = self.session.post(url=urls.cancel_order(), data=data) + return response.json() + + def get_balance_overview(self, account, keywords=None): + """ + Returns a filtered, flattened view of useful balance fields. + + This is a convenience helper over `get_account_balances` to quickly + surface likely relevant numbers such as cash, available cash, and + buying power without needing to know the exact response structure. + + Args: + account (str): Account number to query balances for. + keywords (list[str], optional): Additional case-insensitive substrings + to match in keys. Defaults to a sensible set for balances. + + Returns: + dict: A dict mapping dot-notated keys to values from the balances + response where the key path contains any of the keywords. + """ + if keywords is None: + keywords = [ + "cash", + "avail", + "withdraw", + "buying", + "bp", + "equity", + "value", + "margin", + ] + + payload = self.get_account_balances(account) + + filtered = {} + + def _walk(node, path): + if isinstance(node, dict): + for k, v in node.items(): + _walk(v, path + [str(k)]) + elif isinstance(node, list): + for i, v in enumerate(node): + _walk(v, path + [str(i)]) + else: + key_path = ".".join(path) + low = key_path.lower() + if any(sub in low for sub in keywords): + filtered[key_path] = node + + _walk(payload, []) + return filtered diff --git a/firstrade/exceptions.py b/firstrade/exceptions.py new file mode 100644 index 0000000..3950acd --- /dev/null +++ b/firstrade/exceptions.py @@ -0,0 +1,55 @@ +class QuoteError(Exception): + """Base class for exceptions in the Quote module.""" + + +class QuoteRequestError(QuoteError): + """Exception raised for errors in the HTTP request during a Quote.""" + + def __init__(self, status_code, message="Error in HTTP request"): + self.status_code = status_code + self.message = f"{message}. HTTP status code: {status_code}" + super().__init__(self.message) + + +class QuoteResponseError(QuoteError): + """Exception raised for errors in the API response.""" + + def __init__(self, symbol, error_message): + self.symbol = symbol + self.message = f"Failed to get data for {symbol}. API returned the following error: {error_message}" + super().__init__(self.message) + + +class LoginError(Exception): + """Exception raised for errors in the login process.""" + + +class LoginRequestError(LoginError): + """Exception raised for errors in the HTTP request during login.""" + + def __init__(self, status_code, message="Error in HTTP request during login"): + self.status_code = status_code + self.message = f"{message}. HTTP status code: {status_code}" + super().__init__(self.message) + + +class LoginResponseError(LoginError): + """Exception raised for errors in the API response during login.""" + + def __init__(self, error_message): + self.message = ( + f"Failed to login. API returned the following error: {error_message}" + ) + super().__init__(self.message) + + +class AccountError(Exception): + """Base class for exceptions in the Account module.""" + + +class AccountResponseError(AccountError): + """Exception raised for errors in the API response when getting account data.""" + + def __init__(self, error_message): + self.message = f"Failed to get account data. API returned the following error: {error_message}" + super().__init__(self.message) diff --git a/firstrade/order.py b/firstrade/order.py index a3efe9c..6e9050e 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -1,15 +1,20 @@ from enum import Enum -from bs4 import BeautifulSoup - from firstrade import urls from firstrade.account import FTSession class PriceType(str, Enum): """ - This is an :class: 'enum.Enum' - that contains the valid price types for an order. + Enum for valid price types in an order. + + Attributes: + MARKET (str): Market order, executed at the current market price. + LIMIT (str): Limit order, executed at a specified price or better. + STOP (str): Stop order, becomes a market order once a specified price is reached. + STOP_LIMIT (str): Stop-limit order, becomes a limit order once a specified price is reached. + TRAILING_STOP_DOLLAR (str): Trailing stop order with a specified dollar amount. + TRAILING_STOP_PERCENT (str): Trailing stop order with a specified percentage. """ LIMIT = "2" @@ -22,8 +27,14 @@ class PriceType(str, Enum): class Duration(str, Enum): """ - This is an :class:'~enum.Enum' - that contains the valid durations for an order. + Enum for valid order durations. + + Attributes: + DAY (str): Day order. + GT90 (str): Good till 90 days order. + PRE_MARKET (str): Pre-market order. + AFTER_MARKET (str): After-market order. + DAY_EXT (str): Day extended order. """ DAY = "0" @@ -35,193 +46,201 @@ class Duration(str, Enum): class OrderType(str, Enum): """ - This is an :class:'~enum.Enum' - that contains the valid order types for an order. + Enum for valid order types. + + Attributes: + BUY (str): Buy order. + SELL (str): Sell order. + SELL_SHORT (str): Sell short order. + BUY_TO_COVER (str): Buy to cover order. + BUY_OPTION (str): Buy option order. + SELL_OPTION (str): Sell option order. """ BUY = "B" SELL = "S" SELL_SHORT = "SS" BUY_TO_COVER = "BC" + BUY_OPTION = "BO" + SELL_OPTION = "SO" + + +class OrderInstructions(str, Enum): + """ + Enum for valid order instructions. + + Attributes: + AON (str): All or none. + OPG (str): At the Open. + CLO (str): At the Close. + """ + + AON = "1" + OPG = "4" + CLO = "5" + + +class OptionType(str, Enum): + """ + Enum for valid option types. + + Attributes: + CALL (str): Call option. + PUT (str): Put option. + """ + + CALL = "C" + PUT = "P" class Order: """ - This class contains information about an order. - It also contains a method to place an order. + Represents an order with methods to place it. + + Attributes: + ft_session (FTSession): The session object for placing orders. """ def __init__(self, ft_session: FTSession): self.ft_session = ft_session - self.order_confirmation = {} def place_order( self, - account, - symbol, + account: str, + symbol: str, price_type: PriceType, order_type: OrderType, - quantity, duration: Duration, - price=0.00, - dry_run=True, - notional=False, + quantity: int = 0, + price: float = 0.00, + stop_price: float = None, + dry_run: bool = True, + notional: bool = False, + order_instruction: OrderInstructions = "0", ): """ Builds and places an order. - :attr: 'order_confirmation` - contains the order confirmation data after order placement. Args: - account (str): Account number of the account to place the order in. - symbol (str): Ticker to place the order for. - order_type (PriceType): Price Type i.e. LIMIT, MARKET, STOP, etc. - quantity (float): The number of shares to buy. - duration (Duration): Duration of the order i.e. DAY, GT90, etc. - price (float, optional): The price to buy the shares at. Defaults to 0.00. - dry_run (bool, optional): Whether you want the order to be placed or not. - Defaults to True. + account (str): The account number to place the order in. + symbol (str): The ticker symbol for the order. + price_type (PriceType): The price type for the order (e.g., LIMIT, MARKET, STOP). + order_type (OrderType): The type of order (e.g., BUY, SELL). + duration (Duration): The duration of the order (e.g., DAY, GT90). + quantity (int, optional): The number of shares to buy or sell. Defaults to 0. + price (float, optional): The price at which to buy or sell the shares. Defaults to 0.00. + stop_price (float, optional): The stop price for stop orders. Defaults to None. + dry_run (bool, optional): If True, the order will not be placed but will be built and validated. Defaults to True. + notional (bool, optional): If True, the order will be placed based on a notional dollar amount rather than share quantity. Defaults to False. + order_instruction (OrderInstructions, optional): Additional order instructions (e.g., AON, OPG). Defaults to "0". + + Raises: + ValueError: If AON orders are not limit orders or if AON orders have a quantity of 100 shares or less. + PreviewOrderError: If the order preview fails. + PlaceOrderError: If the order placement fails. Returns: - Order:order_confirmation: Dictionary containing the order confirmation data. + dict: A dictionary containing the order confirmation data. """ - if dry_run: - previewOrders = "1" - else: - previewOrders = "" - - if price_type == PriceType.MARKET: + if price_type == PriceType.MARKET and not notional: price = "" + if order_instruction == OrderInstructions.AON and price_type != PriceType.LIMIT: + raise ValueError("AON orders must be a limit order.") + if order_instruction == OrderInstructions.AON and quantity <= 100: + raise ValueError("AON orders must be greater than 100 shares.") data = { - "submiturl": "/cgi-bin/orderbar", - "orderbar_clordid": "", - "orderbar_accountid": "", - "notional": "yes" if notional else "", - "stockorderpage": "yes", - "submitOrders": "1", - "previewOrders": previewOrders, - "lotMethod": "1", - "accountType": "1", - "quoteprice": "", - "viewederror": "", - "stocksubmittedcompanyname1": "", - "accountId": account, - "transactionType": order_type, - "quantity": quantity, "symbol": symbol, - "priceType": price_type, - "limitPrice": price, + "transaction": order_type, + "shares": quantity, "duration": duration, - "qualifier": "0", - "cond_symbol0_0": "", - "cond_type0_0": "2", - "cond_compare_type0_0": "2", - "cond_compare_value0_0": "", - "cond_and_or0": "1", - "cond_symbol0_1": "", - "cond_type0_1": "2", - "cond_compare_type0_1": "2", - "cond_compare_value0_1": "", + "preview": "true", + "instructions": order_instruction, + "account": account, + "price_type": price_type, + "limit_price": "0", } + if notional: + data["dollar_amount"] = price + del data["shares"] + if price_type in [PriceType.LIMIT, PriceType.STOP_LIMIT]: + data["limit_price"] = price + if price_type in [PriceType.STOP, PriceType.STOP_LIMIT]: + data["stop_price"] = stop_price + response = self.ft_session.post(url=urls.order(), data=data) + if response.status_code != 200 or response.json()["error"] != "": + return response.json() + preview_data = response.json() + if dry_run: + return preview_data + data["preview"] = "false" + data["stage"] = "P" + response = self.ft_session.post(url=urls.order(), data=data) + return response.json() - order_data = BeautifulSoup( - self.ft_session.post( - url=urls.orderbar(), headers=urls.session_headers(), data=data - ).text, - "xml", - ) - order_confirmation = {} - order_success = order_data.find("success").text.strip() - order_confirmation["success"] = order_success - action_data = order_data.find("actiondata").text.strip() - if order_success != "No": - # Extract the table data - table_start = action_data.find("