From c12d6fda9a2f7a91d95549da22ed1baee2d430b3 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Sat, 1 Jun 2024 19:11:35 -0500 Subject: [PATCH 01/68] more detailed quotes --- firstrade/symbols.py | 15 ++++++++++++++- test.py | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/firstrade/symbols.py b/firstrade/symbols.py index 2df0d00..2a9335e 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -42,10 +42,18 @@ def __init__(self, ft_session: FTSession, symbol: str): soup = BeautifulSoup(symbol_data.text, "xml") quote = soup.find("quote") self.symbol = quote.find("symbol").text + self.underlying_symbol = quote.find("underlying_symbol").text + self.tick = quote.find("tick").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.bid_size = int(quote.find("bidsize").text.replace(",", "")) + self.ask_size = int(quote.find("asksize").text.replace(",", "")) + self.last_size = int(quote.find("lastsize").text.replace(",", "")) + self.bid_mmid = quote.find("bidmmid").text + self.ask_mmid = quote.find("askmmid").text + self.last_mmid = quote.find("lastmmid").text self.change = float(quote.find("change").text.replace(",", "")) if quote.find("high").text == "N/A": self.high = None @@ -55,7 +63,12 @@ def __init__(self, ft_session: FTSession, symbol: str): self.low = "None" else: self.low = float(quote.find("low").text.replace(",", "")) + self.change_color = quote.find("changecolor").text self.volume = quote.find("vol").text - self.company_name = quote.find("companyname").text + self.bidxask = quote.find("bidxask").text + self.quote_time = quote.find("quotetime").text + self.last_trade_time = quote.find("lasttradetime").text self.real_time = quote.find("realtime").text == "T" self.fractional = quote.find("fractional").text == "T" + self.err_code = quote.find("errcode").text + self.company_name = quote.find("companyname").text diff --git a/test.py b/test.py index 2718dd9..8483672 100644 --- a/test.py +++ b/test.py @@ -21,14 +21,29 @@ # Get quote for INTC quote = symbols.SymbolQuote(ft_ss, "INTC") print(f"Symbol: {quote.symbol}") +print(f"Underlying Symbol: {quote.underlying_symbol}") +print(f"Tick: {quote.tick}") print(f"Exchange: {quote.exchange}") print(f"Bid: {quote.bid}") print(f"Ask: {quote.ask}") print(f"Last: {quote.last}") +print(f"Bid Size: {quote.bid_size}") +print(f"Ask Size: {quote.ask_size}") +print(f"Last Size: {quote.last_size}") +print(f"Bid MMID: {quote.bid_mmid}") +print(f"Ask MMID: {quote.ask_mmid}") +print(f"Last MMID: {quote.last_mmid}") print(f"Change: {quote.change}") print(f"High: {quote.high}") print(f"Low: {quote.low}") +print(f"Change Color: {quote.change_color}") print(f"Volume: {quote.volume}") +print(f"Bid x Ask: {quote.bidxask}") +print(f"Quote Time: {quote.quote_time}") +print(f"Last Trade Time: {quote.last_trade_time}") +print(f"Real Time: {quote.real_time}") +print(f"Fractional: {quote.fractional}") +print(f"Error Code: {quote.error_code}") print(f"Company Name: {quote.company_name}") # Get positions and print them out for an account. From 652d8b0e3e45db0c0306ab0ebe54e23dfb38608e Mon Sep 17 00:00:00 2001 From: maxxrk Date: Sat, 1 Jun 2024 19:12:10 -0500 Subject: [PATCH 02/68] fix test.py --- test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test.py b/test.py index 8483672..bc32fb9 100644 --- a/test.py +++ b/test.py @@ -43,7 +43,7 @@ print(f"Last Trade Time: {quote.last_trade_time}") print(f"Real Time: {quote.real_time}") print(f"Fractional: {quote.fractional}") -print(f"Error Code: {quote.error_code}") +print(f"Error Code: {quote.err_code}") print(f"Company Name: {quote.company_name}") # Get positions and print them out for an account. From e5c7cf19f01abccd1202e052933b05349f82fa9c Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Sun, 2 Jun 2024 00:27:23 +0000 Subject: [PATCH 03/68] style: format code with Black and isort This commit fixes the style issues introduced in 652d8b0 according to the output from Black and isort. Details: None --- test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test.py b/test.py index bc32fb9..0b67b76 100644 --- a/test.py +++ b/test.py @@ -85,4 +85,4 @@ print(current_orders) # Delete cookies -ft_ss.delete_cookies() \ No newline at end of file +ft_ss.delete_cookies() From f588748e4dab45fc5a7ca88188de2ad930ccb1a0 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Sat, 1 Jun 2024 19:32:11 -0500 Subject: [PATCH 04/68] updateSetup --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 6b0273b..0b82318 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="firstrade", - version="0.0.17", + version="0.0.18", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,7 +13,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0017.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0018.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "beautifulsoup4", "lxml"], packages=["firstrade"], From bbdf540095f0faa9a45861863a95bcd7f4191156 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Fri, 21 Jun 2024 11:13:56 -0500 Subject: [PATCH 05/68] fix quote 0 int values --- firstrade/symbols.py | 9 ++++++--- setup.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/firstrade/symbols.py b/firstrade/symbols.py index 2a9335e..56f3cd1 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -48,9 +48,12 @@ def __init__(self, ft_session: FTSession, symbol: str): 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.bid_size = int(quote.find("bidsize").text.replace(",", "")) - self.ask_size = int(quote.find("asksize").text.replace(",", "")) - self.last_size = int(quote.find("lastsize").text.replace(",", "")) + temp_store = quote.find("bidsize").text.replace(",", "") + self.bid_size = int(temp_store) if temp_store.isdigit() else 0 + temp_store = quote.find("asksize").text.replace(",", "") + self.ask_size = int(temp_store) if temp_store.isdigit() else 0 + temp_store = quote.find("lastsize").text.replace(",", "") + self.last_size = int(temp_store) if temp_store.isdigit() else 0 self.bid_mmid = quote.find("bidmmid").text self.ask_mmid = quote.find("askmmid").text self.last_mmid = quote.find("lastmmid").text diff --git a/setup.py b/setup.py index 0b82318..f823c4a 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="firstrade", - version="0.0.18", + version="0.0.19", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,7 +13,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0018.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0019.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "beautifulsoup4", "lxml"], packages=["firstrade"], From 206c787f15c06c26de0742a8caa573b96140cf3b Mon Sep 17 00:00:00 2001 From: maxxrk Date: Fri, 12 Jul 2024 21:10:41 -0500 Subject: [PATCH 06/68] fix order warnings --- firstrade/order.py | 34 +++++++++++++++++++++++++++------- setup.py | 4 ++-- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/firstrade/order.py b/firstrade/order.py index a3efe9c..96e895e 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -86,10 +86,6 @@ def place_order( Order:order_confirmation: Dictionary containing the order confirmation data. """ - if dry_run: - previewOrders = "1" - else: - previewOrders = "" if price_type == PriceType.MARKET: price = "" @@ -100,8 +96,8 @@ def place_order( "orderbar_accountid": "", "notional": "yes" if notional else "", "stockorderpage": "yes", - "submitOrders": "1", - "previewOrders": previewOrders, + "submitOrders": "", + "previewOrders": "1", "lotMethod": "1", "accountType": "1", "quoteprice": "", @@ -125,7 +121,7 @@ def place_order( "cond_compare_type0_1": "2", "cond_compare_value0_1": "", } - + order_data = BeautifulSoup( self.ft_session.post( url=urls.orderbar(), headers=urls.session_headers(), data=data @@ -133,6 +129,30 @@ def place_order( "xml", ) order_confirmation = {} + cdata = order_data.find("actiondata").string + cdata_soup = BeautifulSoup(cdata, "html.parser") + span = ( + cdata_soup.find('div', class_='msg_bg') + .find('div', class_='yellow box') + .find('div', class_='error_msg') + .find('div', class_='outbox') + .find('div', class_='inbox') + .find('span') + ) + if span: + order_warning = span.text.strip() + order_confirmation["warning"] = order_warning + data["viewederror"] = "1" + if not dry_run: + data["previewOrders"] = "" + data["submitOrders"] = "1" + order_data = BeautifulSoup( + self.ft_session.post( + url=urls.orderbar(), headers=urls.session_headers(), data=data + ).text, + "xml", + ) + order_success = order_data.find("success").text.strip() order_confirmation["success"] = order_success action_data = order_data.find("actiondata").text.strip() diff --git a/setup.py b/setup.py index f823c4a..3c282b0 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="firstrade", - version="0.0.19", + version="0.0.20", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,7 +13,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0019.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0020.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "beautifulsoup4", "lxml"], packages=["firstrade"], From 41f68984a3fddfca3230c016389164b6afdb9807 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Sat, 13 Jul 2024 02:10:57 +0000 Subject: [PATCH 07/68] style: format code with Black and isort This commit fixes the style issues introduced in 206c787 according to the output from Black and isort. Details: None --- firstrade/order.py | 67 +++++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/firstrade/order.py b/firstrade/order.py index 96e895e..972a8d0 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -86,7 +86,6 @@ def place_order( Order:order_confirmation: Dictionary containing the order confirmation data. """ - if price_type == PriceType.MARKET: price = "" @@ -121,7 +120,7 @@ def place_order( "cond_compare_type0_1": "2", "cond_compare_value0_1": "", } - + order_data = BeautifulSoup( self.ft_session.post( url=urls.orderbar(), headers=urls.session_headers(), data=data @@ -132,12 +131,12 @@ def place_order( cdata = order_data.find("actiondata").string cdata_soup = BeautifulSoup(cdata, "html.parser") span = ( - cdata_soup.find('div', class_='msg_bg') - .find('div', class_='yellow box') - .find('div', class_='error_msg') - .find('div', class_='outbox') - .find('div', class_='inbox') - .find('span') + cdata_soup.find("div", class_="msg_bg") + .find("div", class_="yellow box") + .find("div", class_="error_msg") + .find("div", class_="outbox") + .find("div", class_="inbox") + .find("span") ) if span: order_warning = span.text.strip() @@ -152,7 +151,7 @@ def place_order( ).text, "xml", ) - + order_success = order_data.find("success").text.strip() order_confirmation["success"] = order_success action_data = order_data.find("actiondata").text.strip() @@ -197,48 +196,56 @@ def get_orders(ft_session, account): # Data dictionary to send with the request data = { - 'accountId': account, + "accountId": account, } # Post request to retrieve the order data - response = ft_session.post(url=urls.order_list(), headers=urls.session_headers(), data=data).text + 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') + table = soup.find("table", class_="tablesorter") if not table: return [] - rows = table.find_all('tr')[1:] # skip the header row + 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 + 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') + 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) + 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, + "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: From e5920fc34172b90ebed18fde04341871739f99fb Mon Sep 17 00:00:00 2001 From: maxxrk Date: Wed, 24 Jul 2024 22:19:14 -0500 Subject: [PATCH 08/68] add order instructions --- firstrade/order.py | 14 ++++++++++++-- setup.py | 4 ++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/firstrade/order.py b/firstrade/order.py index 972a8d0..1c1ba3b 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -44,6 +44,16 @@ class OrderType(str, Enum): SELL_SHORT = "SS" BUY_TO_COVER = "BC" +class OrderInstructions(str, Enum): + """ + This is an :class:'~enum.Enum' + that contains the valid instructions for an order. + """ + + AON = "1" + OPG = "4" + CLO = "5" + class Order: """ @@ -66,6 +76,7 @@ def place_order( price=0.00, dry_run=True, notional=False, + order_instruction: OrderInstructions = None, ): """ Builds and places an order. @@ -88,7 +99,6 @@ def place_order( if price_type == PriceType.MARKET: price = "" - data = { "submiturl": "/cgi-bin/orderbar", "orderbar_clordid": "", @@ -109,7 +119,7 @@ def place_order( "priceType": price_type, "limitPrice": price, "duration": duration, - "qualifier": "0", + "qualifier": "0" if order_instruction is None else order_instruction, "cond_symbol0_0": "", "cond_type0_0": "2", "cond_compare_type0_0": "2", diff --git a/setup.py b/setup.py index 3c282b0..e5b493e 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="firstrade", - version="0.0.20", + version="0.0.21", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,7 +13,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0020.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0021.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "beautifulsoup4", "lxml"], packages=["firstrade"], From f2da7e048ca7a7617a494935d0f6f0708845803b Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Thu, 25 Jul 2024 03:19:31 +0000 Subject: [PATCH 09/68] style: format code with Black and isort This commit fixes the style issues introduced in e5920fc according to the output from Black and isort. Details: None --- firstrade/order.py | 1 + 1 file changed, 1 insertion(+) diff --git a/firstrade/order.py b/firstrade/order.py index 1c1ba3b..83f0a0b 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -44,6 +44,7 @@ class OrderType(str, Enum): SELL_SHORT = "SS" BUY_TO_COVER = "BC" + class OrderInstructions(str, Enum): """ This is an :class:'~enum.Enum' From dc8c1ef74d0eb7b5c0d895e50c82293e15902882 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Wed, 24 Jul 2024 22:27:55 -0500 Subject: [PATCH 10/68] add AON checks --- firstrade/order.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/firstrade/order.py b/firstrade/order.py index 83f0a0b..17cc0f0 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -100,6 +100,11 @@ def place_order( if price_type == PriceType.MARKET: price = "" + if order_instruction == OrderInstructions.AON and price_type == PriceType.MARKET: + raise ValueError("AON orders cannot be market orders.") + 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": "", From d5b9f5387d29fad39d58bd606b08b7cb7445be32 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Wed, 24 Jul 2024 22:30:04 -0500 Subject: [PATCH 11/68] fix AON checks --- firstrade/order.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firstrade/order.py b/firstrade/order.py index 17cc0f0..c412bc2 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -100,8 +100,8 @@ def place_order( if price_type == PriceType.MARKET: price = "" - if order_instruction == OrderInstructions.AON and price_type == PriceType.MARKET: - raise ValueError("AON orders cannot be market 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 quantity <= 100: raise ValueError("AON orders must be greater than 100 shares.") From 0ac96662a190cfa1cf7749bdf4306884a55e6e52 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Thu, 25 Jul 2024 03:30:18 +0000 Subject: [PATCH 12/68] style: format code with Black and isort This commit fixes the style issues introduced in d5b9f53 according to the output from Black and isort. Details: None --- firstrade/order.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firstrade/order.py b/firstrade/order.py index c412bc2..05e86af 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -104,7 +104,7 @@ def place_order( 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": "", From 0c45a41aac3c820b3628e97dc0191d813766a10d Mon Sep 17 00:00:00 2001 From: maxxrk Date: Thu, 25 Jul 2024 19:09:38 -0500 Subject: [PATCH 13/68] start adding options --- firstrade/order.py | 185 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/firstrade/order.py b/firstrade/order.py index 05e86af..d0dfa90 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -197,6 +197,191 @@ def place_order( order_confirmation["errcode"] = order_data.find("errcode").text.strip() self.order_confirmation = order_confirmation +def place_option_order( + self, + opt_choice, + account, + trans_type, + contracts, + symbol, + exp_date, + strike: float, + call_put_type: int, + price_type: PriceType, + stop_price: float, + order_type: OrderType, + quantity, + duration: Duration, + price=0.00, + dry_run=True, + notional=False, + order_instruction: OrderInstructions = None, +): + + if price_type == PriceType.MARKET: + 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/optionorder_request", + "orderbar_clordid":"", + "orderbar_accountid":"", + "optionorderpage": "yes", + "submitOrders":"", + "previewOrders": 1, + "lotMethod": 1, + "accountType": 2, + "quoteprice":"", + "viewederror":"", + "stocksubmittedcompanyname1":"", + "opt_choice": opt_choice, + "accountId": account, + "transactionType": trans_type, + "contracts": contracts, + "underlyingsymbol": symbol, + "expdate": exp_date, + "strike": strike, + "callputtype": call_put_type, + "priceType": price_type, + "stopPrice": stop_price, + "duration": duration, + "qualifier":"", + "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":"", + "optionspos_dropdown1":"", + "transactionType2":"", + "contracts2":"", + "underlyingsymbol2":"", + "expdate2":"", + "strike2":"", + "optionspos_dropdown2":"", + "transactionType3":"", + "contracts3":"", + "underlyingsymbol3":"", + "expdate3":"", + "strike3":"", + "netprice_sp":"", + "qualifier_sp":"", + "optionspos_dropdown3":"", + "transactionType4":"", + "contracts4":"", + "underlyingsymbol4":"", + "expdate4":"", + "strike4":"", + "transactionType5":"", + "contracts5":"", + "underlyingsymbol5":"", + "expdate5":"", + "strike5":"", + "netprice_st":"", + "qualifier_st":"", + "optionspos_dropdown":"", + "contracts10":"", + "expdate11":"", + "strike11":"", + "netprice_ro":"", + "qualifier_ro":"", + "opt_u_symbol":"", + "mleg_close_dropdown":"", + "transactionType6":"", + "contracts6":"", + "underlyingsymbol6":"", + "expdate6":"", + "strike6":"", + "callputtype6":"P", + "transactionType7":"", + "contracts7":"", + "underlyingsymbol7":"", + "expdate7":"", + "strike7":"", + "callputtype7":"P", + "transactionType8":"", + "contracts8":"", + "underlyingsymbol8":"", + "expdate8":"", + "strike8":"", + "callputtype8":"P", + "transactionType9":"", + "contracts9":"", + "underlyingsymbol9":"", + "expdate9":"", + "strike9":"", + "callputtype9":"P", + "netprice_bf":"", + "qualifier_bf":"", + } + + order_data = BeautifulSoup( + self.ft_session.post( + url=urls.orderbar(), headers=urls.session_headers(), data=data + ).text, + "xml", + ) + order_confirmation = {} + cdata = order_data.find("actiondata").string + cdata_soup = BeautifulSoup(cdata, "html.parser") + span = ( + cdata_soup.find("div", class_="msg_bg") + .find("div", class_="yellow box") + .find("div", class_="error_msg") + .find("div", class_="outbox") + .find("div", class_="inbox") + .find("span") + ) + if span: + order_warning = span.text.strip() + order_confirmation["warning"] = order_warning + data["viewederror"] = "1" + if not dry_run: + data["previewOrders"] = "" + data["submitOrders"] = "1" + order_data = BeautifulSoup( + self.ft_session.post( + url=urls.orderbar(), headers=urls.session_headers(), data=data + ).text, + "xml", + ) + + 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): """ From ac5d95affef7d1a42fd0402cb8e1ad94412f6ad7 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Thu, 25 Jul 2024 19:17:20 -0500 Subject: [PATCH 14/68] adding options --- firstrade/order.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/firstrade/order.py b/firstrade/order.py index d0dfa90..f4e2f06 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -56,6 +56,16 @@ class OrderInstructions(str, Enum): CLO = "5" +class OptionType(str, Enum): + """ + This is an :class:'~enum.Enum' + that contains the valid option types for an order. + """ + + CALL = "C" + PUT = "P" + + class Order: """ This class contains information about an order. @@ -206,12 +216,12 @@ def place_option_order( symbol, exp_date, strike: float, - call_put_type: int, + call_put_type: OptionType, price_type: PriceType, - stop_price: float, order_type: OrderType, quantity, duration: Duration, + stop_price: float = None, price=0.00, dry_run=True, notional=False, @@ -232,9 +242,9 @@ def place_option_order( "orderbar_accountid":"", "optionorderpage": "yes", "submitOrders":"", - "previewOrders": 1, - "lotMethod": 1, - "accountType": 2, + "previewOrders": "1", + "lotMethod": "1", + "accountType": "2", "quoteprice":"", "viewederror":"", "stocksubmittedcompanyname1":"", @@ -247,9 +257,10 @@ def place_option_order( "strike": strike, "callputtype": call_put_type, "priceType": price_type, - "stopPrice": stop_price, + "limitPrice": price if price_type == PriceType.LIMIT else "", + "stopPrice": stop_price if stop_price is not None else "", "duration": duration, - "qualifier":"", + "qualifier":"0" if order_instruction is None else order_instruction, "cond_symbol0_0":"", "cond_type0_0": 2, "cond_compare_type0_0": 2, @@ -299,25 +310,25 @@ def place_option_order( "underlyingsymbol6":"", "expdate6":"", "strike6":"", - "callputtype6":"P", + "callputtype6":call_put_type, "transactionType7":"", "contracts7":"", "underlyingsymbol7":"", "expdate7":"", "strike7":"", - "callputtype7":"P", + "callputtype7":call_put_type, "transactionType8":"", "contracts8":"", "underlyingsymbol8":"", "expdate8":"", "strike8":"", - "callputtype8":"P", + "callputtype8":call_put_type, "transactionType9":"", "contracts9":"", "underlyingsymbol9":"", "expdate9":"", "strike9":"", - "callputtype9":"P", + "callputtype9":call_put_type, "netprice_bf":"", "qualifier_bf":"", } From 2f037a52bff9a8ba17f602dd3cb5442db3e1a6bd Mon Sep 17 00:00:00 2001 From: maxxrk Date: Tue, 6 Aug 2024 19:54:56 -0500 Subject: [PATCH 15/68] authentication/symbols switched --- .gitignore | 1 + firstrade/account.py | 304 ++++++++++++++++++++----------------------- firstrade/symbols.py | 104 ++++++++------- firstrade/urls.py | 45 +++++-- 4 files changed, 236 insertions(+), 218 deletions(-) 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/firstrade/account.py b/firstrade/account.py index bf730bd..a4a4982 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -9,9 +9,9 @@ class FTSession: - """Class creating a session for Firstrade.""" - def __init__(self, username, password, pin, profile_path=None): + """Class creating a session for Firstrade.""" + def __init__(self, username, password, pin=None, email=None, phone=None, profile_path=None): """ Initializes a new instance of the FTSession class. @@ -25,70 +25,88 @@ def __init__(self, username, password, pin, profile_path=None): self.username = username self.password = password self.pin = pin + self.email = self._mask_email(email) if email is not None else None + self.phone = phone 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", - } + 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, + } - 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, + response = self.session.post( + url=urls.login(), + data=data, + ) + self.login_json = response.json() + if "mfa" not in self.login_json and "ftat" in self.login_json and self.login_json["error"] == "": + self.session.headers["sid"] = self.login_json["sid"] + return False + self.t_token = self.login_json.get("t_token") + self.otp_options = self.login_json.get("otp") + if self.login_json["error"] != "" or response.status_code != 200: + raise Exception(f"Login failed api reports the following error(s). {self.login_json['error']}") + + need_code = self._handle_mfa() + if self.login_json["error"]!= "": + raise Exception(f"Login failed api reports the following error(s): {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 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, } - - 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): + response = self.session.post(urls.verify_pin(), data=data) + self.login_json = response.json() + if self.login_json["error"]!= "": + raise Exception(f"Login failed api reports the following error(s): {self.login_json['error']}.") + self.session.headers["ftat"] = self.login_json["ftat"] + self.session.headers["sid"] = self.login_json["sid"] + print(self.login_json) + 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 """ - cookies = {} + + 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 +114,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 - - def save_cookies(self): + ftat = pickle.load(f) + return ftat + + 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 +127,48 @@ 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) + ftat = self.session.headers.get("ftat") + pickle.dump(ftat, f) + + def _mask_email(self, email): + """Masks the email for security purposes.""" + 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 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 _handle_mfa(self): + """Handles multi-factor authentication.""" + if "mfa" in self.login_json and self.pin is not None: + data = { + "pin": self.pin, + "remember_for": "30", + "t_token": self.t_token, + } + response = self.session.post(urls.pin(), data=data) + self.login_json = response.json() + elif "mfa" in self.login_json 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, + } + elif item["channel"] == "email" and self.email is not None: + if self.email == item["recipientMask"]: + data = { + "recipientId": item["recipientId"], + "t_token": self.t_token, + } + response = self.session.post(urls.request_code(), data=data) + print(response.json()) + self.login_json = response.json() + if self.login_json["error"] == "": + self.session.headers["sid"]= self.login_json["verificationSid"] + return False if self.pin is not None else True + def __getattr__(self, name): """ @@ -148,72 +199,31 @@ def __init__(self, session): 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) + response = self.session.get(url=urls.user_info()) + if response.status_code != 200: + raise Exception("Failed to get user info.") + self.user_info = response.json() + response = self.session.get(urls.account_list()) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception(f"Failed to get account list. API returned the following error: {response.json()['error']}") + self.all_accounts = response.json() + for item in self.all_accounts["items"]: + self.account_numbers.append(item["account"]) + self.account_balances.append(float(item["total_value"])) + + def get_account_balances(self, account): + """Gets account balances for a given account. - 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"], - }, - } - } - ) + Args: + account (str): Account number of the account you want to get balances for. - self.all_accounts = all_account_info + Returns: + dict: Dict of the response from the API. + """ + response = self.session.get(urls.account_balances(account)) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception(f"Failed to get account balances. API returned the following error: {response.json()['error']}") + return response.json() def get_positions(self, account): """Gets currently held positions for a given account. @@ -222,36 +232,10 @@ 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. """ - data = { - "page": "pos", - "accountId": str(account), - } - 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.get(urls.account_positions(account)) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception(f"Failed to get account positions. API returned the following error: {response.json()['error']}") + return response.json() diff --git a/firstrade/symbols.py b/firstrade/symbols.py index 56f3cd1..422ca97 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -6,72 +6,84 @@ class SymbolQuote: """ - Dataclass containing quote information for a symbol. + Data class representing a stock quote for a given symbol. Attributes: - ft_session (FTSession): - The session object used for making HTTP requests to Firstrade. + ft_session (FTSession): The session object used for making HTTP requests to Firstrade. symbol (str): The symbol for which the quote information is retrieved. - exchange (str): The exchange where the symbol is traded. - bid (float): The bid price for the symbol. - ask (float): The ask price for the symbol. + sec_type (str): The security type of the symbol. + tick (str): The tick size of the symbol. + bid (int): The bid price for the symbol. + bid_size (int): The size of the bid. + ask (int): The ask price for the symbol. + ask_size (int): The size of the ask. last (float): The last traded price for the symbol. change (float): The change in price for the symbol. high (float): The highest price for the symbol during the trading day. low (float): The lowest price for the symbol during the trading day. + bid_mmid (str): The market maker ID for the bid. + ask_mmid (str): The market maker ID for the ask. + last_mmid (str): The market maker ID for the last trade. + last_size (int): The size of the last trade. + change_color (str): The color indicating the change in price. volume (str): The volume of shares traded for the symbol. + today_close (float): The closing price for the symbol today. + open (str): The opening price for the symbol. + quote_time (str): The time of the quote. + last_trade_time (str): The time of the last trade. company_name (str): The name of the company associated with the symbol. - real_time (bool): If the quote is real-time or not - fractional (bool): If the stock can be traded fractionally, or not + exchange (str): The exchange where the symbol is traded. + has_option (bool): Indicates if the symbol has options. + is_etf (bool): Indicates if the symbol is an ETF. + is_fractional (bool): Indicates if the stock can be traded fractionally. + realtime (str): Indicates if the quote is real-time. + nls (str): Nasdaq last sale. + shares (int): The number of shares. """ - def __init__(self, ft_session: FTSession, symbol: str): + def __init__(self, ft_session: FTSession, account: str, symbol: str): """ Initializes a new instance of the SymbolQuote class. Args: ft_session (FTSession): The session object used for making HTTP requests to Firstrade. + account (str): The account number for which the quote information is retrieved. symbol (str): The symbol for which the quote information is retrieved. """ self.ft_session = ft_session - self.symbol = symbol - symbol_data = self.ft_session.get( - url=urls.quote(self.symbol), headers=urls.session_headers() - ) - soup = BeautifulSoup(symbol_data.text, "xml") - quote = soup.find("quote") - self.symbol = quote.find("symbol").text - self.underlying_symbol = quote.find("underlying_symbol").text - self.tick = quote.find("tick").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(",", "")) - temp_store = quote.find("bidsize").text.replace(",", "") + response = self.ft_session.get(url=urls.quote(account, symbol)) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception(f"Failed to get quote for {symbol}. API returned the following error: {response.json()['error']}") + self.symbol = response.json()["result"]["symbol"] + self.sec_type = response.json()["result"]["sec_type"] + self.tick = response.json()["result"]["tick"] + self.bid = int(response.json()["result"]["bid"].replace(",", "")) + temp_store = response.json()["result"]["bid_size"].replace(",", "") self.bid_size = int(temp_store) if temp_store.isdigit() else 0 - temp_store = quote.find("asksize").text.replace(",", "") + self.ask = int(response.json()["result"]["ask"](",", "")) + temp_store = response.json()["result"]["ask_size"](",", "") self.ask_size = int(temp_store) if temp_store.isdigit() else 0 - temp_store = quote.find("lastsize").text.replace(",", "") + self.last = float(response.json()["result"]["last"].replace(",", "")) + self.change = float(response.json()["result"]["change"].replace(",", "")) + self.high = float(response.json()["result"]["high"].replace(",", "") if response.json()["result"]["high"] != "N/A" else None) + self.low = float(response.json()["result"]["low"].replace(",", "") if response.json()["result"]["low"] != "N/A" else None) + self.bid_mmid = response.json()["result"]["bid_mmid"] + self.ask_mmid = response.json()["result"]["ask_mmid"] + self.last_mmid = response.json()["result"]["last_mmid"] + temp_store = response.json()["result"]["last_size"].replace(",", "") self.last_size = int(temp_store) if temp_store.isdigit() else 0 - self.bid_mmid = quote.find("bidmmid").text - self.ask_mmid = quote.find("askmmid").text - self.last_mmid = quote.find("lastmmid").text - 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.change_color = quote.find("changecolor").text - self.volume = quote.find("vol").text - self.bidxask = quote.find("bidxask").text - self.quote_time = quote.find("quotetime").text - self.last_trade_time = quote.find("lasttradetime").text - self.real_time = quote.find("realtime").text == "T" - self.fractional = quote.find("fractional").text == "T" - self.err_code = quote.find("errcode").text - self.company_name = quote.find("companyname").text + self.change_color = response.json()["result"]["change_color"] + self.volume = response.json()["result"]["vol"] + self.today_close = float(response.json()["result"]["today_close"].replace(",", "")) + self.open = response.json()["result"]["open"].replace(",", "") + self.quote_time = response.json()["result"]["quote_time"] + self.last_trade_time = response.json()["result"]["last_trade_time"] + self.company_name = response.json()["result"]["company_name"] + self.exchange = response.json()["result"]["exchange"] + self.has_option = bool(response.json()["result"]["has_option"]) + self.is_etf = bool(response.json()["result"]["is_etf"]) + self.is_fractional = bool(response.json()["result"]["is_fractional"]) + self.realtime = response.json()["result"]["realtime"] + self.nls = response.json()["result"]["nls"] + self.shares = int(response.json()["result"]["shares"].replace(",", "")) diff --git a/firstrade/urls.py b/firstrade/urls.py index 3b5bac8..1e0ad3f 100644 --- a/firstrade/urls.py +++ b/firstrade/urls.py @@ -3,19 +3,40 @@ def get_xml(): def login(): - return "https://invest.firstrade.com/cgi-bin/login" + #return "https://invest.firstrade.com/cgi-bin/login" + return "https://api3x.firstrade.com/sess/login" def pin(): - return "https://invest.firstrade.com/cgi-bin/enter_pin?destination_page=home" + return "https://api3x.firstrade.com/sess/verify_pin" + + +def request_code(): + return "https://api3x.firstrade.com/sess/request_code" + + +def verify_pin(): + return "https://api3x.firstrade.com/sess/verify_pin" + + +def user_info(): + return "https://api3x.firstrade.com/private/userinfo" def account_list(): - return "https://invest.firstrade.com/cgi-bin/getaccountlist" + return "https://api3x.firstrade.com/private/acct_list" -def quote(symbol): - return f"https://invest.firstrade.com/cgi-bin/getxml?page=quo"eSymbol={symbol}" +def account_balances(account): + return f"https://api3x.firstrade.com/private/balances?account={account}" + + +def account_positions(account): + return f"https://api3x.firstrade.com/private/positions?account={account}&per_page=200" + + +def quote(account, symbol): + return f"https://api3x.firstrade.com/public/quote?account={account}&q={symbol}" def orderbar(): @@ -35,12 +56,12 @@ def status(): def session_headers(): headers = { - "Accept": "*/*", - "Accept-Encoding": "gzip, deflate, br", - "Accept-Language": "en-US,en;q=0.9", - "Host": "invest.firstrade.com", - "Referer": "https://invest.firstrade.com/cgi-bin/main", - "Connection": "keep-alive", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.81", + "Accept-Encoding": "gzip", + "Connection": "Keep-Alive", + "Host": "api3x.firstrade.com", + "User-Agent": "okhttp/4.9.2", } return headers + +def access_token(): + return "833w3XuIFycv18ybi" From f0ed6718bd5dacf2b8719add8be2fcb2b0f4408e Mon Sep 17 00:00:00 2001 From: maxxrk Date: Tue, 6 Aug 2024 19:59:36 -0500 Subject: [PATCH 16/68] remove unused libraries --- firstrade/account.py | 2 -- firstrade/symbols.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index a4a4982..778157f 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -1,9 +1,7 @@ import os import pickle -import re import requests -from bs4 import BeautifulSoup from firstrade import urls diff --git a/firstrade/symbols.py b/firstrade/symbols.py index 422ca97..3db033f 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -1,5 +1,3 @@ -from bs4 import BeautifulSoup - from firstrade import urls from firstrade.account import FTSession From fac73b36434c6f43d091df038c97d74aea0396d4 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Wed, 7 Aug 2024 20:51:43 -0500 Subject: [PATCH 17/68] working on orders --- firstrade/order.py | 384 ++++++++++----------------------------------- firstrade/urls.py | 27 ++-- 2 files changed, 100 insertions(+), 311 deletions(-) diff --git a/firstrade/order.py b/firstrade/order.py index f4e2f06..952e0e9 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -43,6 +43,8 @@ class OrderType(str, Enum): SELL = "S" SELL_SHORT = "SS" BUY_TO_COVER = "BC" + BUY_OPTION = "BO" + SELL_OPTION = "SO" class OrderInstructions(str, Enum): @@ -78,16 +80,16 @@ def __init__(self, ft_session: FTSession): def place_order( self, - account, - symbol, + account: str, + symbol: str, price_type: PriceType, order_type: OrderType, - quantity, + quantity: int, duration: Duration, - price=0.00, - dry_run=True, - notional=False, - order_instruction: OrderInstructions = None, + price: float=0.00, + dry_run: bool=True, + notional: bool=False, + order_instruction: OrderInstructions = "0", ): """ Builds and places an order. @@ -114,98 +116,36 @@ def place_order( 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": "", - "previewOrders": "1", - "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" if order_instruction is None else order_instruction, - "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": price if price_type == PriceType.LIMIT else "0", } - - order_data = BeautifulSoup( - self.ft_session.post( - url=urls.orderbar(), headers=urls.session_headers(), data=data - ).text, - "xml", - ) - order_confirmation = {} - cdata = order_data.find("actiondata").string - cdata_soup = BeautifulSoup(cdata, "html.parser") - span = ( - cdata_soup.find("div", class_="msg_bg") - .find("div", class_="yellow box") - .find("div", class_="error_msg") - .find("div", class_="outbox") - .find("div", class_="inbox") - .find("span") - ) - if span: - order_warning = span.text.strip() - order_confirmation["warning"] = order_warning - data["viewederror"] = "1" - if not dry_run: - data["previewOrders"] = "" - data["submitOrders"] = "1" - order_data = BeautifulSoup( - self.ft_session.post( - url=urls.orderbar(), headers=urls.session_headers(), data=data - ).text, - "xml", + if notional: + data["dollar_ammount"] = price + response = self.ft_session.post(url=urls.order(), data=data) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception( + f"Failed to preview order for {symbol}. API returned the following error: {response.json()['error']}" ) - - 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 + preview_data = response.json() + if dry_run: + return preview_data + data["stage"] = "P" + + response = self.ft_session.post(url=urls.order(), data=data) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception( + f"Failed to place order for {symbol}. API returned the following error: {response.json()['error']}" + ) + self.order_confirmation = response.json() def place_option_order( self, @@ -225,7 +165,7 @@ def place_option_order( price=0.00, dry_run=True, notional=False, - order_instruction: OrderInstructions = None, + order_instruction: OrderInstructions = "0", ): if price_type == PriceType.MARKET: @@ -237,164 +177,38 @@ def place_option_order( data = { - "submiturl": "/cgi-bin/optionorder_request", - "orderbar_clordid":"", - "orderbar_accountid":"", - "optionorderpage": "yes", - "submitOrders":"", - "previewOrders": "1", - "lotMethod": "1", - "accountType": "2", - "quoteprice":"", - "viewederror":"", - "stocksubmittedcompanyname1":"", - "opt_choice": opt_choice, - "accountId": account, - "transactionType": trans_type, - "contracts": contracts, - "underlyingsymbol": symbol, - "expdate": exp_date, - "strike": strike, - "callputtype": call_put_type, - "priceType": price_type, - "limitPrice": price if price_type == PriceType.LIMIT else "", - "stopPrice": stop_price if stop_price is not None else "", "duration": duration, - "qualifier":"0" if order_instruction is None else order_instruction, - "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":"", - "optionspos_dropdown1":"", - "transactionType2":"", - "contracts2":"", - "underlyingsymbol2":"", - "expdate2":"", - "strike2":"", - "optionspos_dropdown2":"", - "transactionType3":"", - "contracts3":"", - "underlyingsymbol3":"", - "expdate3":"", - "strike3":"", - "netprice_sp":"", - "qualifier_sp":"", - "optionspos_dropdown3":"", - "transactionType4":"", - "contracts4":"", - "underlyingsymbol4":"", - "expdate4":"", - "strike4":"", - "transactionType5":"", - "contracts5":"", - "underlyingsymbol5":"", - "expdate5":"", - "strike5":"", - "netprice_st":"", - "qualifier_st":"", - "optionspos_dropdown":"", - "contracts10":"", - "expdate11":"", - "strike11":"", - "netprice_ro":"", - "qualifier_ro":"", - "opt_u_symbol":"", - "mleg_close_dropdown":"", - "transactionType6":"", - "contracts6":"", - "underlyingsymbol6":"", - "expdate6":"", - "strike6":"", - "callputtype6":call_put_type, - "transactionType7":"", - "contracts7":"", - "underlyingsymbol7":"", - "expdate7":"", - "strike7":"", - "callputtype7":call_put_type, - "transactionType8":"", - "contracts8":"", - "underlyingsymbol8":"", - "expdate8":"", - "strike8":"", - "callputtype8":call_put_type, - "transactionType9":"", - "contracts9":"", - "underlyingsymbol9":"", - "expdate9":"", - "strike9":"", - "callputtype9":call_put_type, - "netprice_bf":"", - "qualifier_bf":"", + "instructions": order_instruction, + "transaction": order_type, + "contracts": quantity, + "symbol": symbol, + "preview": "true", + "account": account, + "price_type": price_type, } - order_data = BeautifulSoup( - self.ft_session.post( - url=urls.orderbar(), headers=urls.session_headers(), data=data - ).text, - "xml", + response = self.ft_session.post(url=urls.option_order(), data=data) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception( + f"Failed to preview order for {symbol}. " + f"API returned the following error: {response.json()['error']} " + f"With the following message: {response.json()['message']} " ) - order_confirmation = {} - cdata = order_data.find("actiondata").string - cdata_soup = BeautifulSoup(cdata, "html.parser") - span = ( - cdata_soup.find("div", class_="msg_bg") - .find("div", class_="yellow box") - .find("div", class_="error_msg") - .find("div", class_="outbox") - .find("div", class_="inbox") - .find("span") - ) - if span: - order_warning = span.text.strip() - order_confirmation["warning"] = order_warning - data["viewederror"] = "1" - if not dry_run: - data["previewOrders"] = "" - data["submitOrders"] = "1" - order_data = BeautifulSoup( - self.ft_session.post( - url=urls.orderbar(), headers=urls.session_headers(), data=data - ).text, - "xml", + if dry_run: + return response.json() + data["preview"] = "false" + response = self.ft_session.post(url=urls.option_order(), data=data) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception( + f"Failed to preview order for {symbol}. " + f"API returned the following error: {response.json()['error']} " + f"With the following message: {response.json()['message']} " ) + self.order_confirmation = response.json() - 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): +def get_orders(self, account): """ Retrieves existing order data for a given account. @@ -406,61 +220,35 @@ def get_orders(ft_session, account): list: A list of dictionaries, each containing details about an order. """ + # Post request to retrieve the order data + response = self.ft_session.get(url=urls.order_list(account)) + if response.status_code != 200 and response.json()["error"] != "": + raise Exception(f"Failed to get order list. API returned the following error: {response.json()['error']}") + 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 dictionary to send with the request data = { - "accountId": account, + "order_id": order_id, } - # 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 [] + # Post request to cancel the order + response = self.ft_session.post( + url=urls.cancel_order(), headers=urls.session_headers(), data=data + ) - rows = table.find_all("tr")[1:] # skip the header row + if response.status_code != 200 or response.json()["error"] != "": + raise Exception(f"Failed to cancel order. API returned status code: {response.json()["error"]}") - 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 + # Return the response message + return response.json() diff --git a/firstrade/urls.py b/firstrade/urls.py index 1e0ad3f..0aa5582 100644 --- a/firstrade/urls.py +++ b/firstrade/urls.py @@ -1,7 +1,3 @@ -def get_xml(): - return "https://invest.firstrade.com/cgi-bin/getxml" - - def login(): #return "https://invest.firstrade.com/cgi-bin/login" return "https://api3x.firstrade.com/sess/login" @@ -38,21 +34,26 @@ def account_positions(account): def quote(account, symbol): return f"https://api3x.firstrade.com/public/quote?account={account}&q={symbol}" +def order(): + return "https://api3x.firstrade.com/private/stock_order" -def orderbar(): - return "https://invest.firstrade.com/cgi-bin/orderbar" - +def order_list(account): + return f"https://api3x.firstrade.com/private/order_status?account={account}" -def account_status(): - return "https://invest.firstrade.com/cgi-bin/account_status" +def cancel_order(): + return "https://api3x.firstrade.com/private/cancel_order" -def order_list(): - return "https://invest.firstrade.com/cgi-bin/orderstatus" +def option_dates(symbol): + return f"https://api3x.firstrade.com/public/oc?m=get_exp_dates&root_symbol={symbol}" +def greek_options(): + return "https://api3x.firstrade.com/private/greekoptions/analytical" -def status(): - return "https://invest.firstrade.com/scripts/profile/margin_v2.php" +def option_quote(symbol, date): + return f"https://api3x.firstrade.com/public/oc?m=get_oc&root_symbol={symbol}&exp_date={date}&chains_range=A" +def option_order(): + return "https://api3x.firstrade.com/private/option_order" def session_headers(): headers = { From 930154c16298078e16d1b3e33fb742e2be7e75d9 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Wed, 7 Aug 2024 21:11:17 -0500 Subject: [PATCH 18/68] more order work --- firstrade/order.py | 52 +++++++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/firstrade/order.py b/firstrade/order.py index 952e0e9..c9b160c 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -76,7 +76,6 @@ class Order: def __init__(self, ft_session: FTSession): self.ft_session = ft_session - self.order_confirmation = {} def place_order( self, @@ -86,9 +85,10 @@ def place_order( order_type: OrderType, quantity: int, duration: Duration, - price: float=0.00, - dry_run: bool=True, - notional: bool=False, + price: float = 0.00, + stop_price: float = None, + dry_run: bool = True, + notional: bool = False, order_instruction: OrderInstructions = "0", ): """ @@ -126,14 +126,20 @@ def place_order( "instructions": order_instruction, "account": account, "price_type": price_type, - "limit_price": price if price_type == PriceType.LIMIT else "0", + "limit_price": "0", } if notional: data["dollar_ammount"] = price + 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"] != "": raise Exception( - f"Failed to preview order for {symbol}. API returned the following error: {response.json()['error']}" + f"Failed to preview order for {symbol}. " + f"API returned the following error: {response.json()['error']} " + f"With the following message: {response.json()['message']} " ) preview_data = response.json() if dry_run: @@ -143,33 +149,27 @@ def place_order( response = self.ft_session.post(url=urls.order(), data=data) if response.status_code != 200 or response.json()["error"] != "": raise Exception( - f"Failed to place order for {symbol}. API returned the following error: {response.json()['error']}" + f"Failed to preview order for {symbol}. " + f"API returned the following error: {response.json()['error']} " + f"With the following message: {response.json()['message']} " ) - self.order_confirmation = response.json() + return response.json() def place_option_order( self, - opt_choice, - account, - trans_type, - contracts, - symbol, - exp_date, - strike: float, - call_put_type: OptionType, + account: str, + symbol: str, price_type: PriceType, order_type: OrderType, - quantity, + quantity: int, duration: Duration, stop_price: float = None, - price=0.00, - dry_run=True, - notional=False, + price: float = 0.00, + dry_run: bool = True, order_instruction: OrderInstructions = "0", ): - if price_type == PriceType.MARKET: - 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: @@ -186,7 +186,11 @@ def place_option_order( "account": account, "price_type": price_type, } - + if price_type in [PriceType.LIMIT, PriceType.STOP_LIMIT]: + data["limit_price"] = price + if price_type in [PriceType.STOP, PriceType.STOP_LIMIT]: + data["stop_price"] = stop_price + response = self.ft_session.post(url=urls.option_order(), data=data) if response.status_code != 200 or response.json()["error"] != "": raise Exception( @@ -204,7 +208,7 @@ def place_option_order( f"API returned the following error: {response.json()['error']} " f"With the following message: {response.json()['message']} " ) - self.order_confirmation = response.json() + return response.json() From 36a2b68a4f76ebc8fec12bc7a1a9f8de421c696b Mon Sep 17 00:00:00 2001 From: maxxrk Date: Fri, 9 Aug 2024 18:58:35 -0500 Subject: [PATCH 19/68] add disclaimer --- README.md | 3 +++ firstrade/account.py | 2 -- firstrade/symbols.py | 35 ++++++++++++++++------------------- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 613e8eb..7a49879 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ In order to use Fractional shares you must accept the agreement on the website b I am new to coding and new to open-source. I would love any help and suggestions! +## 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 Install using pypi: diff --git a/firstrade/account.py b/firstrade/account.py index 778157f..18a81fe 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -84,7 +84,6 @@ def login_two(self, code): raise Exception(f"Login failed api reports the following error(s): {self.login_json['error']}.") self.session.headers["ftat"] = self.login_json["ftat"] self.session.headers["sid"] = self.login_json["sid"] - print(self.login_json) self._save_cookies() def delete_cookies(self): @@ -161,7 +160,6 @@ def _handle_mfa(self): "t_token": self.t_token, } response = self.session.post(urls.request_code(), data=data) - print(response.json()) self.login_json = response.json() if self.login_json["error"] == "": self.session.headers["sid"]= self.login_json["verificationSid"] diff --git a/firstrade/symbols.py b/firstrade/symbols.py index 3db033f..3bfe0f0 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -56,32 +56,29 @@ def __init__(self, ft_session: FTSession, account: str, symbol: str): self.symbol = response.json()["result"]["symbol"] self.sec_type = response.json()["result"]["sec_type"] self.tick = response.json()["result"]["tick"] - self.bid = int(response.json()["result"]["bid"].replace(",", "")) - temp_store = response.json()["result"]["bid_size"].replace(",", "") - self.bid_size = int(temp_store) if temp_store.isdigit() else 0 - self.ask = int(response.json()["result"]["ask"](",", "")) - temp_store = response.json()["result"]["ask_size"](",", "") - self.ask_size = int(temp_store) if temp_store.isdigit() else 0 - self.last = float(response.json()["result"]["last"].replace(",", "")) - self.change = float(response.json()["result"]["change"].replace(",", "")) - self.high = float(response.json()["result"]["high"].replace(",", "") if response.json()["result"]["high"] != "N/A" else None) - self.low = float(response.json()["result"]["low"].replace(",", "") if response.json()["result"]["low"] != "N/A" else None) - self.bid_mmid = response.json()["result"]["bid_mmid"] - self.ask_mmid = response.json()["result"]["ask_mmid"] - self.last_mmid = response.json()["result"]["last_mmid"] - temp_store = response.json()["result"]["last_size"].replace(",", "") - self.last_size = int(temp_store) if temp_store.isdigit() else 0 + self.bid = response.json()["result"]["bid"] + self.bid_size = response.json()["result"]["bid_size"] + self.ask = response.json()["result"]["ask"] + self.ask_size = response.json()["result"]["ask_size"] + self.last = response.json()["result"]["last"] + self.change = response.json()["result"]["change"] + self.high = response.json()["result"]["high"] + self.low = response.json()["result"]["low"] + self.bid_mmid = response.json()["result"]["bid_mmid:"] + self.ask_mmid = response.json()["result"]["ask_mmid:"] + self.last_mmid = response.json()["result"]["last_mmid:"] + self.last_size = response.json()["result"]["last_size"] self.change_color = response.json()["result"]["change_color"] self.volume = response.json()["result"]["vol"] - self.today_close = float(response.json()["result"]["today_close"].replace(",", "")) - self.open = response.json()["result"]["open"].replace(",", "") + self.today_close = response.json()["result"]["today_close"] + self.open = response.json()["result"]["open"] self.quote_time = response.json()["result"]["quote_time"] self.last_trade_time = response.json()["result"]["last_trade_time"] self.company_name = response.json()["result"]["company_name"] self.exchange = response.json()["result"]["exchange"] - self.has_option = bool(response.json()["result"]["has_option"]) + self.has_option = response.json()["result"]["has_option"] self.is_etf = bool(response.json()["result"]["is_etf"]) self.is_fractional = bool(response.json()["result"]["is_fractional"]) self.realtime = response.json()["result"]["realtime"] self.nls = response.json()["result"]["nls"] - self.shares = int(response.json()["result"]["shares"].replace(",", "")) + self.shares = response.json()["result"]["shares"] From b8eac7a258e1131a10fe9681c86f09be185337aa Mon Sep 17 00:00:00 2001 From: maxxrk Date: Fri, 9 Aug 2024 23:34:42 -0500 Subject: [PATCH 20/68] some cleanup add option quotes --- firstrade/account.py | 70 ++++++++++++++++++- firstrade/order.py | 159 +++++++++++++++---------------------------- firstrade/symbols.py | 75 ++++++++++++++++++++ firstrade/urls.py | 8 ++- test.py | 73 +++++++++++++------- 5 files changed, 251 insertions(+), 134 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index 18a81fe..8d9011b 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -17,7 +17,8 @@ def __init__(self, username, password, pin=None, email=None, phone=None, profile 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 @@ -128,7 +129,7 @@ def _save_cookies(self): pickle.dump(ftat, f) def _mask_email(self, email): - """Masks the email for security purposes.""" + """Masks the email for use in the api.""" local, domain = email.split('@') masked_local = local[0] + '*' * 4 domain_name, tld = domain.split('.') @@ -161,9 +162,13 @@ def _handle_mfa(self): } response = self.session.post(urls.request_code(), data=data) self.login_json = response.json() + print(self.login_json) if self.login_json["error"] == "": + if self.pin is not None: + self.session.headers["sid"]= self.login_json["sid"] + return False self.session.headers["sid"]= self.login_json["verificationSid"] - return False if self.pin is not None else True + return True def __getattr__(self, name): @@ -235,3 +240,62 @@ def get_positions(self, account): if response.status_code != 200 or response.json()["error"] != "": raise Exception(f"Failed to get account positions. API returned the following error: {response.json()['error']}") return response.json() + + def get_account_history(self, account): + """Gets account history for a given account. + + Args: + account (str): Account number of the account you want to get history for. + + Returns: + dict: Dict of the response from the API. + """ + response = self.session.get(urls.account_history(account)) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception(f"Failed to get account history. API returned the following error: {response.json()['error']}") + 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. + """ + + # Post request to retrieve the order data + response = self.session.get(url=urls.order_list(account)) + if response.status_code != 200 and response.json()["error"] != "": + raise Exception(f"Failed to get order list. API returned the following error: {response.json()['error']}") + 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 dictionary to send with the request + data = { + "order_id": order_id, + } + + # Post request to cancel the order + response = self.session.post( + url=urls.cancel_order(), data=data + ) + + if response.status_code != 200 or response.json()["error"] != "": + raise Exception(f"Failed to cancel order. API returned status code: {response.json()["error"]}") + + # Return the response message + return response.json() \ No newline at end of file diff --git a/firstrade/order.py b/firstrade/order.py index c9b160c..8818cb7 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -1,7 +1,5 @@ from enum import Enum -from bs4 import BeautifulSoup - from firstrade import urls from firstrade.account import FTSession @@ -83,8 +81,8 @@ def place_order( symbol: str, price_type: PriceType, order_type: OrderType, - quantity: int, duration: Duration, + quantity: int = 0, price: float = 0.00, stop_price: float = None, dry_run: bool = True, @@ -110,7 +108,7 @@ def place_order( Order:order_confirmation: 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.") @@ -129,7 +127,8 @@ def place_order( "limit_price": "0", } if notional: - data["dollar_ammount"] = price + 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]: @@ -144,6 +143,7 @@ def place_order( 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) @@ -155,104 +155,57 @@ def place_order( ) return response.json() -def place_option_order( - self, - account: str, - symbol: str, - price_type: PriceType, - order_type: OrderType, - quantity: int, - duration: Duration, - stop_price: float = None, - price: float = 0.00, - dry_run: bool = True, - order_instruction: OrderInstructions = "0", -): - - - 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.") + def place_option_order( + self, + account: str, + symbol: str, + price_type: PriceType, + order_type: OrderType, + quantity: int, + duration: Duration, + stop_price: float = None, + price: float = 0.00, + dry_run: bool = True, + order_instruction: OrderInstructions = "0", + ): - - data = { - "duration": duration, - "instructions": order_instruction, - "transaction": order_type, - "contracts": quantity, - "symbol": 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 + + 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 = { + "duration": duration, + "instructions": order_instruction, + "transaction": order_type, + "contracts": quantity, + "symbol": symbol, + "preview": "true", + "account": account, + "price_type": price_type, + } + if price_type in [PriceType.LIMIT, PriceType.STOP_LIMIT]: + data["limit_price"] = price + if price_type in [PriceType.STOP, PriceType.STOP_LIMIT]: + data["stop_price"] = stop_price - response = self.ft_session.post(url=urls.option_order(), data=data) - if response.status_code != 200 or response.json()["error"] != "": - raise Exception( - f"Failed to preview order for {symbol}. " - f"API returned the following error: {response.json()['error']} " - f"With the following message: {response.json()['message']} " - ) - if dry_run: + response = self.ft_session.post(url=urls.option_order(), data=data) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception( + f"Failed to preview order for {symbol}. " + f"API returned the following error: {response.json()['error']} " + f"With the following message: {response.json()['message']} " + ) + if dry_run: + return response.json() + data["preview"] = "false" + response = self.ft_session.post(url=urls.option_order(), data=data) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception( + f"Failed to preview order for {symbol}. " + f"API returned the following error: {response.json()['error']} " + f"With the following message: {response.json()['message']} " + ) return response.json() - data["preview"] = "false" - response = self.ft_session.post(url=urls.option_order(), data=data) - if response.status_code != 200 or response.json()["error"] != "": - raise Exception( - f"Failed to preview order for {symbol}. " - f"API returned the following error: {response.json()['error']} " - f"With the following message: {response.json()['message']} " - ) - 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. - """ - - # Post request to retrieve the order data - response = self.ft_session.get(url=urls.order_list(account)) - if response.status_code != 200 and response.json()["error"] != "": - raise Exception(f"Failed to get order list. API returned the following error: {response.json()['error']}") - 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 dictionary to send with the request - data = { - "order_id": order_id, - } - - # Post request to cancel the order - response = self.ft_session.post( - url=urls.cancel_order(), headers=urls.session_headers(), data=data - ) - - if response.status_code != 200 or response.json()["error"] != "": - raise Exception(f"Failed to cancel order. API returned status code: {response.json()["error"]}") - - # Return the response message - return response.json() diff --git a/firstrade/symbols.py b/firstrade/symbols.py index 3bfe0f0..41c012a 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -82,3 +82,78 @@ def __init__(self, ft_session: FTSession, account: str, symbol: str): self.realtime = response.json()["result"]["realtime"] self.nls = response.json()["result"]["nls"] self.shares = response.json()["result"]["shares"] + + +class OptionQuote: + """ + Data class representing an option quote for a given symbol. + + Attributes: + ft_session (FTSession): The session object used for making HTTP requests to Firstrade. + symbol (str): The symbol for which the option quote information is retrieved. + option_dates (dict): A dict of expiration dates for options on the given symbol. + """ + + def __init__(self, ft_session: FTSession, symbol: str): + """ + Initializes a new instance of the OptionQuote class. + + Args: + ft_session (FTSession): + The session object used for making HTTP requests to Firstrade. + symbol (str): The symbol for which the option quote information is retrieved. + """ + self.ft_session = ft_session + self.symbol = symbol + self.option_dates = self.get_option_dates(symbol) + + def get_option_dates(self, symbol: str): + """ + Retrieves the expiration dates for options on a given symbol. + + Args: + symbol (str): The symbol for which the expiration dates are retrieved. + + Returns: + list: A list of expiration dates for options on the given symbol. + """ + response = self.ft_session.get(url=urls.option_dates(symbol)) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception(f"Failed to get option dates for {symbol}. API returned the following error: {response.json()['error']}") + return response.json() + + def get_option_quote(self, symbol: str, date: str): + """ + Retrieves the quote for a given option symbol. + + Args: + symbol (str): The symbol for which the quote is retrieved. + + Returns: + dict: A dictionary containing the quote for the given option symbol. + """ + response = self.ft_session.get(url=urls.option_quotes(symbol, date)) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception(f"Failed to get option quote for {symbol}. API returned the following error: {response.json()['error']}") + return response.json() + + def get_greek_options(self, symbol: str, exp_date: str): + """ + Retrieves the greeks for options on a given symbol. + + Args: + symbol (str): The symbol for which the greeks are retrieved. + + Returns: + dict: A dictionary containing the greeks for options on the given symbol. + """ + data = { + "type": "chain", + "chains_range": "A", + "root_symbol": symbol, + "exp_date": exp_date, + } + response = self.ft_session.post(url=urls.greek_options(), data=data) + if response.status_code != 200 or response.json()["error"] != "": + raise Exception(f"Failed to get option greeks for {symbol}. API returned the following error: {response.json()['error']}") + return response.json() diff --git a/firstrade/urls.py b/firstrade/urls.py index 0aa5582..a5f9303 100644 --- a/firstrade/urls.py +++ b/firstrade/urls.py @@ -40,17 +40,21 @@ def order(): def order_list(account): return f"https://api3x.firstrade.com/private/order_status?account={account}" +def account_history(account): + return f"https://api3x.firstrade.com/private/account_history?range=ytd&page=1&account={account}&per_page=200" + def cancel_order(): return "https://api3x.firstrade.com/private/cancel_order" def option_dates(symbol): return f"https://api3x.firstrade.com/public/oc?m=get_exp_dates&root_symbol={symbol}" +def option_quotes(symbol, date): + return f"https://api3x.firstrade.com/public/oc?m=get_oc&root_symbol={symbol}&exp_date={date}&chains_range=A" + def greek_options(): return "https://api3x.firstrade.com/private/greekoptions/analytical" -def option_quote(symbol, date): - return f"https://api3x.firstrade.com/public/oc?m=get_oc&root_symbol={symbol}&exp_date={date}&chains_range=A" def option_order(): return "https://api3x.firstrade.com/private/option_order" diff --git a/test.py b/test.py index 0b67b76..a83b643 100644 --- a/test.py +++ b/test.py @@ -1,8 +1,12 @@ from firstrade import account, order, symbols -from firstrade.order import get_orders # Create a session -ft_ss = account.FTSession(username="", password="", pin="") +ft_ss = account.FTSession(username="", password="", pin="") #Can also replace pin with phone or email + +need_code = ft_ss.login() +if need_code: + code = input("Please enter the pin sent to your email/phone: ") + success = ft_ss.login_two(code) # Get account data ft_accounts = account.FTAccountData(ft_ss) @@ -19,9 +23,8 @@ print(ft_accounts.account_balances) # Get quote for INTC -quote = symbols.SymbolQuote(ft_ss, "INTC") +quote = symbols.SymbolQuote(ft_ss, ft_accounts.account_numbers[0], "INTC") print(f"Symbol: {quote.symbol}") -print(f"Underlying Symbol: {quote.underlying_symbol}") print(f"Tick: {quote.tick}") print(f"Exchange: {quote.exchange}") print(f"Bid: {quote.bid}") @@ -38,51 +41,69 @@ print(f"Low: {quote.low}") print(f"Change Color: {quote.change_color}") print(f"Volume: {quote.volume}") -print(f"Bid x Ask: {quote.bidxask}") print(f"Quote Time: {quote.quote_time}") print(f"Last Trade Time: {quote.last_trade_time}") -print(f"Real Time: {quote.real_time}") -print(f"Fractional: {quote.fractional}") -print(f"Error Code: {quote.err_code}") +print(f"Real Time: {quote.realtime}") +print(f"Fractional: {quote.is_fractional}") print(f"Company Name: {quote.company_name}") # Get positions and print them out for an account. positions = ft_accounts.get_positions(account=ft_accounts.account_numbers[1]) -for key in ft_accounts.securities_held: +for item in positions["items"]: print( - f"Quantity {ft_accounts.securities_held[key]['quantity']} of security {key} held in account {ft_accounts.account_numbers[1]}" + f"Quantity {item["quantity"]} of security {item["symbol"]} held in account {ft_accounts.account_numbers[1]}" ) +# Get account history (past 200) +history = ft_accounts.get_account_history(account=ft_accounts.account_numbers[0]) +for item in history["items"]: + print(f"Transaction: {item["symbol"]} on {item["report_date"]} for {item["amount"]}.") + + # Create an order object. ft_order = order.Order(ft_ss) -# Place order and print out order confirmation data. -ft_order.place_order( +# Place dry run order and print out order confirmation data. +order_conf = ft_order.place_order( ft_accounts.account_numbers[0], symbol="INTC", price_type=order.PriceType.MARKET, order_type=order.OrderType.BUY, - quantity=1, duration=order.Duration.DAY, + quantity=1, dry_run=True, ) -# Print Order data Dict -print(ft_order.order_confirmation) - -# Check if order was successful -if ft_order.order_confirmation["success"] == "Yes": - print("Order placed successfully.") - # Print Order ID - print(f"Order ID: {ft_order.order_confirmation['orderid']}.") +if "order_id" not in order_conf["result"]: + print("Dry run complete.") + print(order_conf["result"]) else: - print("Failed to place order.") - # Print errormessage - print(ft_order.order_confirmation["actiondata"]) + print("Order placed successfully.") + print(f"Order ID: {order_conf["result"]["order_id"]}.") + print(f"Order State: {order_conf["result"]["state"]}.") + +# Cancel placed order +#cancel = ft_accounts.cancel_order(order_conf['result']["order_id"]) +#if cancel["result"]["result"] == "success": + #print("Order cancelled successfully.") +#print(cancel) # Check orders -current_orders = get_orders(ft_ss, ft_accounts.account_numbers[0]) -print(current_orders) +recent_orders = ft_accounts.get_orders(ft_accounts.account_numbers[0]) +print(recent_orders) + +#Get option dates +option_first = symbols.OptionQuote(ft_ss, "INTC") +option_first.option_dates +for item in option_first.option_dates["items"]: + print(f"Expiration Date: {item["exp_date"]} Days Left: {item["day_left"]} Expiration Type: {item["exp_type"]}") + +# Get option quote +option_quote = option_first.get_option_quote("INTC", option_first.option_dates["items"][0]["exp_date"]) +print(option_quote) + +# Get option greeks +option_greeks = option_first.get_greek_options("INTC", option_first.option_dates["items"][0]["exp_date"]) # Delete cookies ft_ss.delete_cookies() From 2763c8bfaf295c96fb678a4616f29e70c9ee9792 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Thu, 15 Aug 2024 05:24:11 -0500 Subject: [PATCH 21/68] add totp docstrings --- firstrade/account.py | 157 ++++++++++++++++++++++++++++++---------- firstrade/exceptions.py | 74 +++++++++++++++++++ firstrade/order.py | 152 ++++++++++++++++++++++++++------------ firstrade/symbols.py | 51 +++++++++---- firstrade/urls.py | 4 - setup.py | 4 +- test.py | 29 ++++++-- 7 files changed, 361 insertions(+), 110 deletions(-) create mode 100644 firstrade/exceptions.py diff --git a/firstrade/account.py b/firstrade/account.py index 8d9011b..3555f1c 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -1,15 +1,53 @@ import os import pickle +import pyotp import requests from firstrade import urls +from firstrade.exceptions import LoginRequestError, LoginResponseError, AccountRequestError, AccountResponseError class FTSession: - """Class creating a session for Firstrade.""" - def __init__(self, username, password, pin=None, email=None, phone=None, profile_path=None): + """ + Class creating a session for Firstrade. + + 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. @@ -26,6 +64,7 @@ def __init__(self, username, password, pin=None, email=None, phone=None, profile self.pin = pin self.email = self._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 @@ -33,13 +72,20 @@ def __init__(self, username, password, pin=None, email=None, phone=None, profile self.session = requests.Session() def login(self): - """Method to validate and login to the Firstrade platform.""" + """ + Validates and logs 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 = 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() @@ -57,13 +103,16 @@ def login(self): self.session.headers["sid"] = self.login_json["sid"] return False self.t_token = self.login_json.get("t_token") - self.otp_options = self.login_json.get("otp") - if self.login_json["error"] != "" or response.status_code != 200: - raise Exception(f"Login failed api reports the following error(s). {self.login_json['error']}") - + 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 Exception(f"Login failed api reports the following error(s): {self.login_json['error']}.") + raise LoginResponseError(self.login_json['error']) if need_code: return True self.session.headers["ftat"] = self.login_json["ftat"] @@ -82,7 +131,7 @@ def login_two(self, code): response = self.session.post(urls.verify_pin(), data=data) self.login_json = response.json() if self.login_json["error"]!= "": - raise Exception(f"Login failed api reports the following error(s): {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() @@ -100,7 +149,7 @@ def _load_cookies(self): Checks if session cookies were saved. Returns: - Dict: Dictionary of cookies. Nom Nom + str: The saved session token. """ ftat = "" @@ -129,7 +178,15 @@ def _save_cookies(self): pickle.dump(ftat, f) def _mask_email(self, email): - """Masks the email for use in the api.""" + """ + 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('.') @@ -137,8 +194,16 @@ def _mask_email(self, email): return f"{masked_local}@{masked_domain}.{tld}" def _handle_mfa(self): - """Handles multi-factor authentication.""" - if "mfa" in self.login_json and self.pin is not None: + """ + 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", @@ -146,7 +211,7 @@ def _handle_mfa(self): } response = self.session.post(urls.pin(), data=data) self.login_json = response.json() - elif "mfa" in self.login_json and (self.email is not None or self.phone is not None): + 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"]: @@ -161,14 +226,23 @@ def _handle_mfa(self): "t_token": self.t_token, } 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() print(self.login_json) if self.login_json["error"] == "": - if self.pin is not None: - self.session.headers["sid"]= self.login_json["sid"] + 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 + else: + self.session.headers["sid"]= self.login_json["verificationSid"] + return True def __getattr__(self, name): @@ -202,11 +276,13 @@ def __init__(self, session): self.account_balances = [] response = self.session.get(url=urls.user_info()) if response.status_code != 200: - raise Exception("Failed to get user info.") + raise AccountRequestError(response.status_code) self.user_info = response.json() response = self.session.get(urls.account_list()) - if response.status_code != 200 or response.json()["error"] != "": - raise Exception(f"Failed to get account list. API returned the following error: {response.json()['error']}") + if response.status_code != 200: + raise AccountRequestError(response.status_code) + if 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"]) @@ -222,8 +298,10 @@ def get_account_balances(self, account): dict: Dict of the response from the API. """ response = self.session.get(urls.account_balances(account)) - if response.status_code != 200 or response.json()["error"] != "": - raise Exception(f"Failed to get account balances. API returned the following error: {response.json()['error']}") + if response.status_code != 200: + raise AccountRequestError(response.status_code) + if response.json()["error"] != "": + raise AccountResponseError(response.json()['error']) return response.json() def get_positions(self, account): @@ -237,8 +315,10 @@ def get_positions(self, account): """ response = self.session.get(urls.account_positions(account)) - if response.status_code != 200 or response.json()["error"] != "": - raise Exception(f"Failed to get account positions. API returned the following error: {response.json()['error']}") + if response.status_code != 200: + raise AccountRequestError(response.status_code) + if response.json()["error"] != "": + raise AccountResponseError(response.json()['error']) return response.json() def get_account_history(self, account): @@ -251,8 +331,10 @@ def get_account_history(self, account): dict: Dict of the response from the API. """ response = self.session.get(urls.account_history(account)) - if response.status_code != 200 or response.json()["error"] != "": - raise Exception(f"Failed to get account history. API returned the following error: {response.json()['error']}") + if response.status_code != 200: + raise AccountRequestError(response.status_code) + if response.json()["error"] != "": + raise AccountResponseError(response.json()['error']) return response.json() def get_orders(self, account): @@ -267,10 +349,11 @@ def get_orders(self, account): list: A list of dictionaries, each containing details about an order. """ - # Post request to retrieve the order data response = self.session.get(url=urls.order_list(account)) - if response.status_code != 200 and response.json()["error"] != "": - raise Exception(f"Failed to get order list. API returned the following error: {response.json()['error']}") + if response.status_code != 200: + raise AccountRequestError(response.status_code) + if response.json()["error"] != "": + raise AccountResponseError(response.json()['error']) return response.json() def cancel_order(self, order_id): @@ -284,18 +367,16 @@ def cancel_order(self, order_id): dict: A dictionary containing the response data. """ - # Data dictionary to send with the request data = { "order_id": order_id, } - # Post request to cancel the order response = self.session.post( url=urls.cancel_order(), data=data ) - if response.status_code != 200 or response.json()["error"] != "": - raise Exception(f"Failed to cancel order. API returned status code: {response.json()["error"]}") - - # Return the response message + if response.status_code != 200: + raise AccountRequestError(response.status_code) + if response.json()["error"] != "": + raise AccountResponseError(response.json()['error']) return response.json() \ No newline at end of file diff --git a/firstrade/exceptions.py b/firstrade/exceptions.py new file mode 100644 index 0000000..7535d9a --- /dev/null +++ b/firstrade/exceptions.py @@ -0,0 +1,74 @@ +class OrderError(Exception): + """Base class for exceptions in the order module.""" + pass + +class PreviewOrderError(OrderError): + """Exception raised for errors in the order preview.""" + def __init__(self, symbol, error, message): + self.symbol = symbol + self.error = error + self.message = message + super().__init__( + f"Failed to preview order for {symbol}. \nAPI returned the following error: {error} \nWith the following message: {message}" + ) + +class PlaceOrderError(OrderError): + """Exception raised for errors in placing the order.""" + def __init__(self, symbol, error, message): + self.symbol = symbol + self.error = error + self.message = message + super().__init__( + f"Failed to place order for {symbol}. \nAPI returned the following error: {error} \nWith the following message: {message}" + ) + +class QuoteError(Exception): + """Base class for exceptions in the Quote module.""" + pass + +class QuoteRequestError(QuoteError): + """Exception raised for errors in the HTTP request.""" + 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.""" + pass + +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.""" + pass + +class AccountRequestError(AccountError): + """Exception raised for errors in the HTTP request.""" + 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 AccountResponseError(AccountError): + """Exception raised for errors in the API response.""" + 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 8818cb7..041d596 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -2,12 +2,20 @@ from firstrade import urls from firstrade.account import FTSession +from firstrade.exceptions import PreviewOrderError, PlaceOrderError 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" @@ -20,8 +28,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" @@ -33,8 +47,15 @@ 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" @@ -47,8 +68,12 @@ class OrderType(str, Enum): class OrderInstructions(str, Enum): """ - This is an :class:'~enum.Enum' - that contains the valid instructions for an order. + Enum for valid order instructions. + + Attributes: + AON (str): All or none. + OPG (str): At the Open. + CLO (str): At the Close. """ AON = "1" @@ -58,8 +83,11 @@ class OrderInstructions(str, Enum): class OptionType(str, Enum): """ - This is an :class:'~enum.Enum' - that contains the valid option types for an order. + Enum for valid option types. + + Attributes: + CALL (str): Call option. + PUT (str): Put option. """ CALL = "C" @@ -68,8 +96,10 @@ class OptionType(str, Enum): 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): @@ -91,21 +121,27 @@ def place_order( ): """ 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 price_type == PriceType.MARKET and not notional: @@ -135,10 +171,10 @@ def place_order( data["stop_price"] = stop_price response = self.ft_session.post(url=urls.order(), data=data) if response.status_code != 200 or response.json()["error"] != "": - raise Exception( - f"Failed to preview order for {symbol}. " - f"API returned the following error: {response.json()['error']} " - f"With the following message: {response.json()['message']} " + raise PreviewOrderError( + symbol, + response.json().get("error", "Unknown error"), + response.json().get("message", "No message provided") ) preview_data = response.json() if dry_run: @@ -148,40 +184,62 @@ def place_order( response = self.ft_session.post(url=urls.order(), data=data) if response.status_code != 200 or response.json()["error"] != "": - raise Exception( - f"Failed to preview order for {symbol}. " - f"API returned the following error: {response.json()['error']} " - f"With the following message: {response.json()['message']} " + raise PlaceOrderError( + symbol, + response.json().get("error", "Unknown error"), + response.json().get("message", "No message provided") ) return response.json() def place_option_order( self, account: str, - symbol: str, - price_type: PriceType, + option_symbol: str, + price_type: PriceType, order_type: OrderType, - quantity: int, + contracts: int, duration: Duration, stop_price: float = None, price: float = 0.00, dry_run: bool = True, order_instruction: OrderInstructions = "0", ): - + """ + Builds and places an option order. + + Args: + account (str): The account number to place the order in. + option_symbol (str): The option ticker symbol for the order. + price_type (PriceType): The price type for the order (e.g., LIMIT, MARKET, STOP). + order_type (OrderType): The type of order (e.g., BUY, SELL). + contracts (int): The number of option contracts to buy or sell. + duration (Duration): The duration of the order (e.g., DAY, GT90). + stop_price (float, optional): The stop price for stop orders. Defaults to None. + price (float, optional): The price at which to buy or sell the option contracts. Defaults to 0.00. + dry_run (bool, optional): If True, the order will not be placed but will be built and validated. Defaults to True. + order_instruction (OrderInstructions, optional): Additional order instructions (e.g., AON, OPG). Defaults to "0". + + Raises: + ValueError: If AON orders are not limit orders or if AON orders have a quantity of 100 contracts or less. + PreviewOrderError: If there is an error during the preview of the order. + PlaceOrderError: If there is an error during the placement of the order. + + Returns: + dict: A dictionary containing the order confirmation data. + """ 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.") + 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": quantity, - "symbol": symbol, + "contracts": contracts, + "symbol": option_symbol, "preview": "true", "account": account, "price_type": price_type, @@ -193,19 +251,19 @@ def place_option_order( response = self.ft_session.post(url=urls.option_order(), data=data) if response.status_code != 200 or response.json()["error"] != "": - raise Exception( - f"Failed to preview order for {symbol}. " - f"API returned the following error: {response.json()['error']} " - f"With the following message: {response.json()['message']} " + raise PreviewOrderError( + option_symbol, + response.json().get("error", "Unknown error"), + response.json().get("message", "No message provided") ) if dry_run: return response.json() data["preview"] = "false" response = self.ft_session.post(url=urls.option_order(), data=data) if response.status_code != 200 or response.json()["error"] != "": - raise Exception( - f"Failed to preview order for {symbol}. " - f"API returned the following error: {response.json()['error']} " - f"With the following message: {response.json()['message']} " + raise PlaceOrderError( + option_symbol, + response.json().get("error", "Unknown error"), + response.json().get("message", "No message provided") ) return response.json() diff --git a/firstrade/symbols.py b/firstrade/symbols.py index 41c012a..ff6b7ed 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -1,5 +1,6 @@ from firstrade import urls from firstrade.account import FTSession +from firstrade.exceptions import QuoteRequestError, QuoteResponseError class SymbolQuote: @@ -44,15 +45,20 @@ def __init__(self, ft_session: FTSession, account: str, symbol: str): Initializes a new instance of the SymbolQuote class. Args: - ft_session (FTSession): - The session object used for making HTTP requests to Firstrade. + ft_session (FTSession): The session object used for making HTTP requests to Firstrade. account (str): The account number for which the quote information is retrieved. symbol (str): The symbol for which the quote information is retrieved. + + Raises: + QuoteRequestError: If the quote request fails with a non-200 status code. + QuoteResponseError: If the quote response contains an error message. """ self.ft_session = ft_session response = self.ft_session.get(url=urls.quote(account, symbol)) - if response.status_code != 200 or response.json()["error"] != "": - raise Exception(f"Failed to get quote for {symbol}. API returned the following error: {response.json()['error']}") + if response.status_code != 200: + raise QuoteRequestError(response.status_code) + if response.json().get("error", "") != "": + raise QuoteResponseError(symbol, response.json()["error"]) self.symbol = response.json()["result"]["symbol"] self.sec_type = response.json()["result"]["sec_type"] self.tick = response.json()["result"]["tick"] @@ -115,11 +121,17 @@ def get_option_dates(self, symbol: str): symbol (str): The symbol for which the expiration dates are retrieved. Returns: - list: A list of expiration dates for options on the given symbol. + dict: A dict of expiration dates and other information for options on the given symbol. + + Raises: + QuoteRequestError: If the request for option dates fails with a non-200 status code. + QuoteResponseError: If the response for option dates contains an error message. """ response = self.ft_session.get(url=urls.option_dates(symbol)) - if response.status_code != 200 or response.json()["error"] != "": - raise Exception(f"Failed to get option dates for {symbol}. API returned the following error: {response.json()['error']}") + if response.status_code != 200: + raise QuoteRequestError(response.status_code) + if response.json().get("error", "") != "": + raise QuoteResponseError(symbol, response.json()["error"]) return response.json() def get_option_quote(self, symbol: str, date: str): @@ -130,11 +142,17 @@ def get_option_quote(self, symbol: str, date: str): symbol (str): The symbol for which the quote is retrieved. Returns: - dict: A dictionary containing the quote for the given option symbol. + dict: A dictionary containing the quote and other information for the given option symbol. + + Raises: + QuoteRequestError: If the request for the option quote fails with a non-200 status code. + QuoteResponseError: If the response for the option quote contains an error message. """ response = self.ft_session.get(url=urls.option_quotes(symbol, date)) - if response.status_code != 200 or response.json()["error"] != "": - raise Exception(f"Failed to get option quote for {symbol}. API returned the following error: {response.json()['error']}") + if response.status_code != 200: + raise QuoteRequestError(response.status_code) + if response.json().get("error", "") != "": + raise QuoteResponseError(symbol, response.json()["error"]) return response.json() def get_greek_options(self, symbol: str, exp_date: str): @@ -143,9 +161,14 @@ def get_greek_options(self, symbol: str, exp_date: str): 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 options on the given symbol. + dict: A dictionary containing the greeks for the options on the given symbol. + + Raises: + QuoteRequestError: If the request for the greeks fails with a non-200 status code. + QuoteResponseError: If the response for the greeks contains an error message. """ data = { "type": "chain", @@ -154,6 +177,8 @@ def get_greek_options(self, symbol: str, exp_date: str): "exp_date": exp_date, } response = self.ft_session.post(url=urls.greek_options(), data=data) - if response.status_code != 200 or response.json()["error"] != "": - raise Exception(f"Failed to get option greeks for {symbol}. API returned the following error: {response.json()['error']}") + if response.status_code != 200: + raise QuoteRequestError(response.status_code) + if response.json().get("error", "") != "": + raise QuoteResponseError(symbol, response.json()["error"]) return response.json() diff --git a/firstrade/urls.py b/firstrade/urls.py index a5f9303..515fc02 100644 --- a/firstrade/urls.py +++ b/firstrade/urls.py @@ -3,10 +3,6 @@ def login(): return "https://api3x.firstrade.com/sess/login" -def pin(): - return "https://api3x.firstrade.com/sess/verify_pin" - - def request_code(): return "https://api3x.firstrade.com/sess/request_code" diff --git a/setup.py b/setup.py index e5b493e..ab89af8 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="firstrade", - version="0.0.21", + version="0.0.30", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,7 +13,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0021.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0030.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "beautifulsoup4", "lxml"], packages=["firstrade"], diff --git a/test.py b/test.py index a83b643..8f13744 100644 --- a/test.py +++ b/test.py @@ -1,8 +1,8 @@ from firstrade import account, order, symbols +from firstrade.exceptions import PreviewOrderError # Create a session -ft_ss = account.FTSession(username="", password="", pin="") #Can also replace pin with phone or email - +ft_ss = account.FTSession(username="", password="", mfa_secret = "", profile_path="") need_code = ft_ss.login() if need_code: code = input("Please enter the pin sent to your email/phone: ") @@ -93,17 +93,34 @@ print(recent_orders) #Get option dates -option_first = symbols.OptionQuote(ft_ss, "INTC") +option_first = symbols.OptionQuote(ft_ss, "M") option_first.option_dates for item in option_first.option_dates["items"]: print(f"Expiration Date: {item["exp_date"]} Days Left: {item["day_left"]} Expiration Type: {item["exp_type"]}") # Get option quote -option_quote = option_first.get_option_quote("INTC", option_first.option_dates["items"][0]["exp_date"]) +option_quote = option_first.get_option_quote("M", option_first.option_dates["items"][0]["exp_date"]) print(option_quote) # Get option greeks -option_greeks = option_first.get_greek_options("INTC", option_first.option_dates["items"][0]["exp_date"]) +option_greeks = option_first.get_greek_options("M", option_first.option_dates["items"][0]["exp_date"]) +#print(option_greeks) + +print(f"Placing dry option order for {option_quote["items"][0]["opt_symbol"]} with a price of {option_quote["items"][0]["ask"]}.") +print("Symbol readable ticker 'M'") +# Place dry option order +try: + option_order = ft_order.place_option_order( + account=ft_accounts.account_numbers[0], + option_symbol=option_quote["items"][0]["opt_symbol"], + order_type=order.OrderType.BUY_OPTION, + price_type=order.PriceType.MARKET, + duration=order.Duration.DAY, + contracts=1, + dry_run=True, + ) +except PreviewOrderError as e: + print(e) # Delete cookies -ft_ss.delete_cookies() +ft_ss.delete_cookies() \ No newline at end of file From 2b3add83b40bbaa91335d0721edf69d909126bb3 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Thu, 15 Aug 2024 20:07:05 -0500 Subject: [PATCH 22/68] update account --- firstrade/account.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index 3555f1c..f2166f6 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -209,7 +209,7 @@ def _handle_mfa(self): "remember_for": "30", "t_token": self.t_token, } - response = self.session.post(urls.pin(), data=data) + 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: @@ -272,8 +272,7 @@ def __init__(self, session): self.session = session self.all_accounts = [] self.account_numbers = [] - self.account_statuses = [] - self.account_balances = [] + self.account_balances = {} response = self.session.get(url=urls.user_info()) if response.status_code != 200: raise AccountRequestError(response.status_code) @@ -286,7 +285,7 @@ def __init__(self, session): self.all_accounts = response.json() for item in self.all_accounts["items"]: self.account_numbers.append(item["account"]) - self.account_balances.append(float(item["total_value"])) + self.account_balances[item['account']] = item["total_value"] def get_account_balances(self, account): """Gets account balances for a given account. From cb4890c30a59290142dc7f5881994b2daaf15653 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Fri, 16 Aug 2024 21:51:09 -0500 Subject: [PATCH 23/68] remove unneded exceptions --- firstrade/account.py | 31 +++---------------------------- firstrade/exceptions.py | 34 ++-------------------------------- firstrade/order.py | 25 ++----------------------- firstrade/symbols.py | 12 ------------ firstrade/urls.py | 10 +++++++++- 5 files changed, 16 insertions(+), 96 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index f2166f6..4ab892e 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -274,14 +274,10 @@ def __init__(self, session): self.account_numbers = [] self.account_balances = {} response = self.session.get(url=urls.user_info()) - if response.status_code != 200: - raise AccountRequestError(response.status_code) self.user_info = response.json() response = self.session.get(urls.account_list()) - if response.status_code != 200: - raise AccountRequestError(response.status_code) - if response.json()["error"] != "": - raise AccountResponseError(response.json()['error']) + 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"]) @@ -296,11 +292,7 @@ def get_account_balances(self, account): Returns: dict: Dict of the response from the API. """ - response = self.session.get(urls.account_balances(account)) - if response.status_code != 200: - raise AccountRequestError(response.status_code) - if response.json()["error"] != "": - raise AccountResponseError(response.json()['error']) + response = self.session.get(urls.account_balances(account)) return response.json() def get_positions(self, account): @@ -314,10 +306,6 @@ def get_positions(self, account): """ response = self.session.get(urls.account_positions(account)) - if response.status_code != 200: - raise AccountRequestError(response.status_code) - if response.json()["error"] != "": - raise AccountResponseError(response.json()['error']) return response.json() def get_account_history(self, account): @@ -330,10 +318,6 @@ def get_account_history(self, account): dict: Dict of the response from the API. """ response = self.session.get(urls.account_history(account)) - if response.status_code != 200: - raise AccountRequestError(response.status_code) - if response.json()["error"] != "": - raise AccountResponseError(response.json()['error']) return response.json() def get_orders(self, account): @@ -349,10 +333,6 @@ def get_orders(self, account): """ response = self.session.get(url=urls.order_list(account)) - if response.status_code != 200: - raise AccountRequestError(response.status_code) - if response.json()["error"] != "": - raise AccountResponseError(response.json()['error']) return response.json() def cancel_order(self, order_id): @@ -373,9 +353,4 @@ def cancel_order(self, order_id): response = self.session.post( url=urls.cancel_order(), data=data ) - - if response.status_code != 200: - raise AccountRequestError(response.status_code) - if response.json()["error"] != "": - raise AccountResponseError(response.json()['error']) return response.json() \ No newline at end of file diff --git a/firstrade/exceptions.py b/firstrade/exceptions.py index 7535d9a..b0e9e84 100644 --- a/firstrade/exceptions.py +++ b/firstrade/exceptions.py @@ -1,33 +1,9 @@ -class OrderError(Exception): - """Base class for exceptions in the order module.""" - pass - -class PreviewOrderError(OrderError): - """Exception raised for errors in the order preview.""" - def __init__(self, symbol, error, message): - self.symbol = symbol - self.error = error - self.message = message - super().__init__( - f"Failed to preview order for {symbol}. \nAPI returned the following error: {error} \nWith the following message: {message}" - ) - -class PlaceOrderError(OrderError): - """Exception raised for errors in placing the order.""" - def __init__(self, symbol, error, message): - self.symbol = symbol - self.error = error - self.message = message - super().__init__( - f"Failed to place order for {symbol}. \nAPI returned the following error: {error} \nWith the following message: {message}" - ) - class QuoteError(Exception): """Base class for exceptions in the Quote module.""" pass class QuoteRequestError(QuoteError): - """Exception raised for errors in the HTTP request.""" + """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}" @@ -61,14 +37,8 @@ class AccountError(Exception): """Base class for exceptions in the Account module.""" pass -class AccountRequestError(AccountError): - """Exception raised for errors in the HTTP request.""" - 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 AccountResponseError(AccountError): - """Exception raised for errors in the API response.""" + """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 041d596..3cc77a5 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -171,24 +171,13 @@ def place_order( data["stop_price"] = stop_price response = self.ft_session.post(url=urls.order(), data=data) if response.status_code != 200 or response.json()["error"] != "": - raise PreviewOrderError( - symbol, - response.json().get("error", "Unknown error"), - response.json().get("message", "No message provided") - ) + 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) - if response.status_code != 200 or response.json()["error"] != "": - raise PlaceOrderError( - symbol, - response.json().get("error", "Unknown error"), - response.json().get("message", "No message provided") - ) return response.json() def place_option_order( @@ -251,19 +240,9 @@ def place_option_order( response = self.ft_session.post(url=urls.option_order(), data=data) if response.status_code != 200 or response.json()["error"] != "": - raise PreviewOrderError( - option_symbol, - response.json().get("error", "Unknown error"), - response.json().get("message", "No message provided") - ) + return response.json() if dry_run: return response.json() data["preview"] = "false" response = self.ft_session.post(url=urls.option_order(), data=data) - if response.status_code != 200 or response.json()["error"] != "": - raise PlaceOrderError( - option_symbol, - response.json().get("error", "Unknown error"), - response.json().get("message", "No message provided") - ) return response.json() diff --git a/firstrade/symbols.py b/firstrade/symbols.py index ff6b7ed..9cc6263 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -128,10 +128,6 @@ def get_option_dates(self, symbol: str): QuoteResponseError: If the response for option dates contains an error message. """ response = self.ft_session.get(url=urls.option_dates(symbol)) - if response.status_code != 200: - raise QuoteRequestError(response.status_code) - if response.json().get("error", "") != "": - raise QuoteResponseError(symbol, response.json()["error"]) return response.json() def get_option_quote(self, symbol: str, date: str): @@ -149,10 +145,6 @@ def get_option_quote(self, symbol: str, date: str): QuoteResponseError: If the response for the option quote contains an error message. """ response = self.ft_session.get(url=urls.option_quotes(symbol, date)) - if response.status_code != 200: - raise QuoteRequestError(response.status_code) - if response.json().get("error", "") != "": - raise QuoteResponseError(symbol, response.json()["error"]) return response.json() def get_greek_options(self, symbol: str, exp_date: str): @@ -177,8 +169,4 @@ def get_greek_options(self, symbol: str, exp_date: str): "exp_date": exp_date, } response = self.ft_session.post(url=urls.greek_options(), data=data) - if response.status_code != 200: - raise QuoteRequestError(response.status_code) - if response.json().get("error", "") != "": - raise QuoteResponseError(symbol, response.json()["error"]) return response.json() diff --git a/firstrade/urls.py b/firstrade/urls.py index 515fc02..8d9016f 100644 --- a/firstrade/urls.py +++ b/firstrade/urls.py @@ -1,5 +1,4 @@ def login(): - #return "https://invest.firstrade.com/cgi-bin/login" return "https://api3x.firstrade.com/sess/login" @@ -30,24 +29,31 @@ def account_positions(account): def quote(account, symbol): return f"https://api3x.firstrade.com/public/quote?account={account}&q={symbol}" + def order(): return "https://api3x.firstrade.com/private/stock_order" + def order_list(account): return f"https://api3x.firstrade.com/private/order_status?account={account}" + def account_history(account): return f"https://api3x.firstrade.com/private/account_history?range=ytd&page=1&account={account}&per_page=200" + def cancel_order(): return "https://api3x.firstrade.com/private/cancel_order" + def option_dates(symbol): return f"https://api3x.firstrade.com/public/oc?m=get_exp_dates&root_symbol={symbol}" + def option_quotes(symbol, date): return f"https://api3x.firstrade.com/public/oc?m=get_oc&root_symbol={symbol}&exp_date={date}&chains_range=A" + def greek_options(): return "https://api3x.firstrade.com/private/greekoptions/analytical" @@ -55,6 +61,7 @@ def greek_options(): def option_order(): return "https://api3x.firstrade.com/private/option_order" + def session_headers(): headers = { "Accept-Encoding": "gzip", @@ -64,5 +71,6 @@ def session_headers(): } return headers + def access_token(): return "833w3XuIFycv18ybi" From a553147dc0bfe96fe8905fa0cc8c2e07a878941c Mon Sep 17 00:00:00 2001 From: maxxrk Date: Fri, 16 Aug 2024 23:26:45 -0500 Subject: [PATCH 24/68] cleanup --- README.md | 160 ++++++++++++++++++++++++++++++++++++++++--- firstrade/account.py | 6 +- firstrade/order.py | 1 - test.py | 44 ++++++------ 4 files changed, 176 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 7a49879..a887ec7 100644 --- a/README.md +++ b/README.md @@ -27,33 +27,175 @@ pip install firstrade ## Quikstart -`Checkout test.py for sample code.` - -This code will: +The code below 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 +- Contains a cancel order example +- Get an option Dates, Quotes, and Greeks +- Place a dry run option order + +```python +from firstrade import account, order, symbols + +# Create a session +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) +if len(ft_accounts.account_numbers) < 1: + raise Exception("No accounts found or an error occured exiting...") + +# Print ALL account data +print(ft_accounts.all_accounts) + +# Print 1st account number. +print(ft_accounts.account_numbers[0]) + +# Print ALL accounts market values. +print(ft_accounts.account_balances) + +# Get quote for INTC +quote = symbols.SymbolQuote(ft_ss, ft_accounts.account_numbers[0], "INTC") +print(f"Symbol: {quote.symbol}") +print(f"Tick: {quote.tick}") +print(f"Exchange: {quote.exchange}") +print(f"Bid: {quote.bid}") +print(f"Ask: {quote.ask}") +print(f"Last: {quote.last}") +print(f"Bid Size: {quote.bid_size}") +print(f"Ask Size: {quote.ask_size}") +print(f"Last Size: {quote.last_size}") +print(f"Bid MMID: {quote.bid_mmid}") +print(f"Ask MMID: {quote.ask_mmid}") +print(f"Last MMID: {quote.last_mmid}") +print(f"Change: {quote.change}") +print(f"High: {quote.high}") +print(f"Low: {quote.low}") +print(f"Change Color: {quote.change_color}") +print(f"Volume: {quote.volume}") +print(f"Quote Time: {quote.quote_time}") +print(f"Last Trade Time: {quote.last_trade_time}") +print(f"Real Time: {quote.realtime}") +print(f"Fractional: {quote.is_fractional}") +print(f"Company Name: {quote.company_name}") + +# Get positions and print them out for an account. +positions = ft_accounts.get_positions(account=ft_accounts.account_numbers[1]) +print(positions) +for item in positions["items"]: + print( + f"Quantity {item["quantity"]} of security {item["symbol"]} held in account {ft_accounts.account_numbers[1]}" + ) + +# Get account history (past 200) +history = ft_accounts.get_account_history(account=ft_accounts.account_numbers[0]) +for item in history["items"]: + print(f"Transaction: {item["symbol"]} on {item["report_date"]} for {item["amount"]}.") + + +# Create an order object. +ft_order = order.Order(ft_ss) + +# Place 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.LIMIT, + order_type=order.OrderType.BUY, + duration=order.Duration.DAY, + quantity=1, + dry_run=True, +) + +print(order_conf) + +if "order_id" not in order_conf["result"]: + print("Dry run complete.") + print(order_conf["result"]) +else: + print("Order placed successfully.") + print(f"Order ID: {order_conf["result"]["order_id"]}.") + print(f"Order State: {order_conf["result"]["state"]}.") + +# Cancel placed order +#cancel = ft_accounts.cancel_order(order_conf['result']["order_id"]) +#if cancel["result"]["result"] == "success": + #print("Order cancelled successfully.") +#print(cancel) + +# Check orders +recent_orders = ft_accounts.get_orders(ft_accounts.account_numbers[0]) +print(recent_orders) + +#Get option dates +option_first = symbols.OptionQuote(ft_ss, "INTC") +option_first.option_dates +for item in option_first.option_dates["items"]: + print(f"Expiration Date: {item["exp_date"]} Days Left: {item["day_left"]} Expiration Type: {item["exp_type"]}") + +# Get option quote +option_quote = option_first.get_option_quote("INTC", option_first.option_dates["items"][0]["exp_date"]) +print(option_quote) + +# Get option greeks +option_greeks = option_first.get_greek_options("INTC", option_first.option_dates["items"][0]["exp_date"]) +print(option_greeks) + +print(f"Placing dry option order for {option_quote["items"][0]["opt_symbol"]} with a price of {option_quote["items"][0]["ask"]}.") +print("Symbol readable ticker 'INTC'") + +# Place dry option order +option_order = ft_order.place_option_order( + account=ft_accounts.account_numbers[0], + option_symbol=option_quote["items"][0]["opt_symbol"], + order_type=order.OrderType.BUY_OPTION, + price_type=order.PriceType.MARKET, + duration=order.Duration.DAY, + contracts=1, + dry_run=True, +) + +print(option_order) + +# Delete cookies +ft_ss.delete_cookies() +``` -`Checkout test.py for sample code.` +`You can also find this code in test.py` --- ## 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 unfamiliar with 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 ISSUES + ## 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 4ab892e..2cf78af 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -5,7 +5,7 @@ import requests from firstrade import urls -from firstrade.exceptions import LoginRequestError, LoginResponseError, AccountRequestError, AccountResponseError +from firstrade.exceptions import LoginRequestError, LoginResponseError, AccountResponseError class FTSession: @@ -108,8 +108,7 @@ def login(self): if response.status_code != 200: raise LoginRequestError(response.status_code) if self.login_json["error"] != "": - raise LoginResponseError(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']) @@ -235,7 +234,6 @@ def _handle_mfa(self): } response = self.session.post(urls.verify_pin(), data=data) self.login_json = response.json() - print(self.login_json) if self.login_json["error"] == "": if self.pin or self.mfa_secret is not None: self.session.headers["sid"] = self.login_json["sid"] diff --git a/firstrade/order.py b/firstrade/order.py index 3cc77a5..528c3bd 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -2,7 +2,6 @@ from firstrade import urls from firstrade.account import FTSession -from firstrade.exceptions import PreviewOrderError, PlaceOrderError class PriceType(str, Enum): diff --git a/test.py b/test.py index 8f13744..d719cbc 100644 --- a/test.py +++ b/test.py @@ -1,12 +1,11 @@ from firstrade import account, order, symbols -from firstrade.exceptions import PreviewOrderError # Create a session -ft_ss = account.FTSession(username="", password="", mfa_secret = "", profile_path="") +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: ") - success = ft_ss.login_two(code) + ft_ss.login_two(code) # Get account data ft_accounts = account.FTAccountData(ft_ss) @@ -49,6 +48,7 @@ # Get positions and print them out for an account. positions = ft_accounts.get_positions(account=ft_accounts.account_numbers[1]) +print(positions) for item in positions["items"]: print( f"Quantity {item["quantity"]} of security {item["symbol"]} held in account {ft_accounts.account_numbers[1]}" @@ -67,13 +67,15 @@ 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, duration=order.Duration.DAY, quantity=1, dry_run=True, ) +print(order_conf) + if "order_id" not in order_conf["result"]: print("Dry run complete.") print(order_conf["result"]) @@ -93,34 +95,34 @@ print(recent_orders) #Get option dates -option_first = symbols.OptionQuote(ft_ss, "M") +option_first = symbols.OptionQuote(ft_ss, "INTC") option_first.option_dates for item in option_first.option_dates["items"]: print(f"Expiration Date: {item["exp_date"]} Days Left: {item["day_left"]} Expiration Type: {item["exp_type"]}") # Get option quote -option_quote = option_first.get_option_quote("M", option_first.option_dates["items"][0]["exp_date"]) +option_quote = option_first.get_option_quote("INTC", option_first.option_dates["items"][0]["exp_date"]) print(option_quote) # Get option greeks -option_greeks = option_first.get_greek_options("M", option_first.option_dates["items"][0]["exp_date"]) -#print(option_greeks) +option_greeks = option_first.get_greek_options("INTC", option_first.option_dates["items"][0]["exp_date"]) +print(option_greeks) print(f"Placing dry option order for {option_quote["items"][0]["opt_symbol"]} with a price of {option_quote["items"][0]["ask"]}.") -print("Symbol readable ticker 'M'") +print("Symbol readable ticker 'INTC'") + # Place dry option order -try: - option_order = ft_order.place_option_order( - account=ft_accounts.account_numbers[0], - option_symbol=option_quote["items"][0]["opt_symbol"], - order_type=order.OrderType.BUY_OPTION, - price_type=order.PriceType.MARKET, - duration=order.Duration.DAY, - contracts=1, - dry_run=True, - ) -except PreviewOrderError as e: - print(e) +option_order = ft_order.place_option_order( + account=ft_accounts.account_numbers[0], + option_symbol=option_quote["items"][0]["opt_symbol"], + order_type=order.OrderType.BUY_OPTION, + price_type=order.PriceType.MARKET, + duration=order.Duration.DAY, + contracts=1, + dry_run=True, +) + +print(option_order) # Delete cookies ft_ss.delete_cookies() \ No newline at end of file From 769d09727b1e59a90539fe4f422be9f941de6f4f Mon Sep 17 00:00:00 2001 From: maxxrk Date: Sat, 17 Aug 2024 22:45:47 -0500 Subject: [PATCH 25/68] fix stylings --- README.md | 1 - firstrade/account.py | 12 ++++++------ test.py | 9 ++++----- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a887ec7..303c1bb 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,6 @@ print(recent_orders) #Get option dates option_first = symbols.OptionQuote(ft_ss, "INTC") -option_first.option_dates for item in option_first.option_dates["items"]: print(f"Expiration Date: {item["exp_date"]} Days Left: {item["day_left"]} Expiration Type: {item["exp_type"]}") diff --git a/firstrade/account.py b/firstrade/account.py index 2cf78af..91e1d43 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -62,7 +62,7 @@ def __init__(self, username, password, pin=None, email=None, phone=None, mfa_sec self.username = username self.password = password self.pin = pin - self.email = self._mask_email(email) if email is not None else None + 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 @@ -175,8 +175,9 @@ def _save_cookies(self): with open(path, "wb") as f: ftat = self.session.headers.get("ftat") pickle.dump(ftat, f) - - def _mask_email(self, email): + + @staticmethod + def _mask_email(email): """ Masks the email for use in the API. @@ -238,9 +239,8 @@ def _handle_mfa(self): if self.pin or self.mfa_secret is not None: self.session.headers["sid"] = self.login_json["sid"] return False - else: - self.session.headers["sid"]= self.login_json["verificationSid"] - return True + self.session.headers["sid"]= self.login_json["verificationSid"] + return True def __getattr__(self, name): diff --git a/test.py b/test.py index d719cbc..f4db4ae 100644 --- a/test.py +++ b/test.py @@ -85,10 +85,10 @@ print(f"Order State: {order_conf["result"]["state"]}.") # Cancel placed order -#cancel = ft_accounts.cancel_order(order_conf['result']["order_id"]) -#if cancel["result"]["result"] == "success": - #print("Order cancelled successfully.") -#print(cancel) +# cancel = ft_accounts.cancel_order(order_conf['result']["order_id"]) +# if cancel["result"]["result"] == "success": + # print("Order cancelled successfully.") +# print(cancel) # Check orders recent_orders = ft_accounts.get_orders(ft_accounts.account_numbers[0]) @@ -96,7 +96,6 @@ #Get option dates option_first = symbols.OptionQuote(ft_ss, "INTC") -option_first.option_dates for item in option_first.option_dates["items"]: print(f"Expiration Date: {item["exp_date"]} Days Left: {item["day_left"]} Expiration Type: {item["exp_type"]}") From 9bbf99a606bb64a13bd16530619554a60d9bd6d1 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Sat, 17 Aug 2024 22:47:20 -0500 Subject: [PATCH 26/68] more style fixes --- firstrade/account.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index 91e1d43..d136863 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -176,7 +176,7 @@ def _save_cookies(self): ftat = self.session.headers.get("ftat") pickle.dump(ftat, f) - @staticmethod + @staticmethod def _mask_email(email): """ Masks the email for use in the API. @@ -239,7 +239,7 @@ def _handle_mfa(self): 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"] + self.session.headers["sid"] = self.login_json["verificationSid"] return True From 65d704e3eda43beb4224587911990b09d53bbfa0 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Sun, 18 Aug 2024 03:47:34 +0000 Subject: [PATCH 27/68] style: format code with Black and isort This commit fixes the style issues introduced in 9bbf99a according to the output from Black and isort. Details: None --- firstrade/account.py | 90 ++++++++++++++++++++++++++------------------ 1 file changed, 54 insertions(+), 36 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index d136863..29662d2 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -1,15 +1,18 @@ import os import pickle -import pyotp +import pyotp import requests from firstrade import urls -from firstrade.exceptions import LoginRequestError, LoginResponseError, AccountResponseError +from firstrade.exceptions import ( + AccountResponseError, + LoginRequestError, + LoginResponseError, +) class FTSession: - """ Class creating a session for Firstrade. @@ -47,7 +50,17 @@ class FTSession: _handle_mfa(): Handles multi-factor authentication. """ - def __init__(self, username, password, pin=None, email=None, phone=None, mfa_secret=None, profile_path=None): + + 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. @@ -88,7 +101,7 @@ def login(self): 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, @@ -99,7 +112,11 @@ def login(self): data=data, ) self.login_json = response.json() - if "mfa" not in self.login_json and "ftat" in self.login_json and self.login_json["error"] == "": + if ( + "mfa" not in self.login_json + and "ftat" in self.login_json + and self.login_json["error"] == "" + ): self.session.headers["sid"] = self.login_json["sid"] return False self.t_token = self.login_json.get("t_token") @@ -108,10 +125,10 @@ def login(self): if response.status_code != 200: raise LoginRequestError(response.status_code) if self.login_json["error"] != "": - raise LoginResponseError(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 self.login_json["error"] != "": + raise LoginResponseError(self.login_json["error"]) if need_code: return True self.session.headers["ftat"] = self.login_json["ftat"] @@ -126,23 +143,23 @@ def login_two(self, 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']) + 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): + + 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. @@ -152,7 +169,9 @@ def _load_cookies(self): """ ftat = "" - directory = os.path.abspath(self.profile_path) if self.profile_path is not None else "." + directory = ( + os.path.abspath(self.profile_path) if self.profile_path is not None else "." + ) if not os.path.exists(directory): os.makedirs(directory) @@ -162,7 +181,7 @@ def _load_cookies(self): with open(filepath, "rb") as f: ftat = pickle.load(f) return ftat - + def _save_cookies(self): """Saves session cookies to a file.""" if self.profile_path is not None: @@ -175,7 +194,7 @@ def _save_cookies(self): with open(path, "wb") as f: ftat = self.session.headers.get("ftat") pickle.dump(ftat, f) - + @staticmethod def _mask_email(email): """ @@ -187,12 +206,12 @@ def _mask_email(email): 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 + 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. @@ -207,11 +226,13 @@ def _handle_mfa(self): data = { "pin": self.pin, "remember_for": "30", - "t_token": self.t_token, + "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): + 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"]: @@ -223,7 +244,7 @@ def _handle_mfa(self): if self.email == item["recipientMask"]: data = { "recipientId": item["recipientId"], - "t_token": self.t_token, + "t_token": self.t_token, } response = self.session.post(urls.request_code(), data=data) elif self.login_json["mfa"] and self.mfa_secret is not None: @@ -242,7 +263,6 @@ def _handle_mfa(self): self.session.headers["sid"] = self.login_json["verificationSid"] return True - def __getattr__(self, name): """ Forwards unknown attribute access to session object. @@ -279,7 +299,7 @@ def __init__(self, session): 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"] + self.account_balances[item["account"]] = item["total_value"] def get_account_balances(self, account): """Gets account balances for a given account. @@ -290,7 +310,7 @@ def get_account_balances(self, account): Returns: dict: Dict of the response from the API. """ - response = self.session.get(urls.account_balances(account)) + response = self.session.get(urls.account_balances(account)) return response.json() def get_positions(self, account): @@ -302,10 +322,10 @@ def get_positions(self, account): Returns: 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): """Gets account history for a given account. @@ -317,7 +337,7 @@ def get_account_history(self, account): """ response = self.session.get(urls.account_history(account)) return response.json() - + def get_orders(self, account): """ Retrieves existing order data for a given account. @@ -348,7 +368,5 @@ def cancel_order(self, order_id): "order_id": order_id, } - response = self.session.post( - url=urls.cancel_order(), data=data - ) - return response.json() \ No newline at end of file + response = self.session.post(url=urls.cancel_order(), data=data) + return response.json() From d9d83766708a78c7613cbfa7569de4702fad38c8 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Sat, 17 Aug 2024 22:48:08 -0500 Subject: [PATCH 28/68] fix readme --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 303c1bb..4bff0be 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,10 @@ else: print(f"Order State: {order_conf["result"]["state"]}.") # Cancel placed order -#cancel = ft_accounts.cancel_order(order_conf['result']["order_id"]) -#if cancel["result"]["result"] == "success": - #print("Order cancelled successfully.") -#print(cancel) +# cancel = ft_accounts.cancel_order(order_conf['result']["order_id"]) +# if cancel["result"]["result"] == "success": + # print("Order cancelled successfully.") +# print(cancel) # Check orders recent_orders = ft_accounts.get_orders(ft_accounts.account_numbers[0]) From 92648d51c8c1288f69030b87814658b96283feb3 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Sun, 18 Aug 2024 15:40:34 -0500 Subject: [PATCH 29/68] update readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4bff0be..417897c 100644 --- a/README.md +++ b/README.md @@ -190,11 +190,11 @@ ft_ss.delete_cookies() ## Options -### I am unfamiliar with options trading and have not fully tested this feature. +### 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 ISSUES - +- 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 From 704a09261a707909669ae55c0ee5a3a81ffac381 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Wed, 28 Aug 2024 21:57:29 -0500 Subject: [PATCH 30/68] add error if account not ok --- firstrade/account.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/firstrade/account.py b/firstrade/account.py index 29662d2..2aeb149 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -1,3 +1,4 @@ +import json import os import pickle @@ -111,7 +112,10 @@ def login(self): url=urls.login(), data=data, ) - self.login_json = response.json() + try: + self.login_json = response.json() + except json.decoder.JSONDecodeError: + raise LoginResponseError("Invalid JSON is your account funded?") if ( "mfa" not in self.login_json and "ftat" in self.login_json From 02dfc29079f2186498ac7ff0f7ce657ce923dc5b Mon Sep 17 00:00:00 2001 From: maxxrk Date: Wed, 28 Aug 2024 21:57:54 -0500 Subject: [PATCH 31/68] bump version --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ab89af8..63216ee 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="firstrade", - version="0.0.30", + version="0.0.31", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,7 +13,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0030.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0031.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "beautifulsoup4", "lxml"], packages=["firstrade"], From da39e39cfe1e5e8501a689ed9a964f1382546468 Mon Sep 17 00:00:00 2001 From: lichhuan Date: Fri, 31 Jan 2025 00:37:48 -0800 Subject: [PATCH 32/68] Add account history query functionality with custom date range and include necessary error checks when using custom range --- firstrade/account.py | 9 +++++++-- firstrade/urls.py | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index 2aeb149..1305c9c 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -330,16 +330,21 @@ def get_positions(self, account): response = self.session.get(urls.account_positions(account)) return response.json() - def get_account_history(self, account): + 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. """ - response = self.session.get(urls.account_history(account)) + 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): diff --git a/firstrade/urls.py b/firstrade/urls.py index 8d9016f..6545805 100644 --- a/firstrade/urls.py +++ b/firstrade/urls.py @@ -38,8 +38,10 @@ def order_list(account): return f"https://api3x.firstrade.com/private/order_status?account={account}" -def account_history(account): - return f"https://api3x.firstrade.com/private/account_history?range=ytd&page=1&account={account}&per_page=200" +def account_history(account, date_range, custom_range): + if custom_range is None: + return f"https://api3x.firstrade.com/private/account_history?range={date_range}&page=1&account={account}&per_page=1000" + return f"https://api3x.firstrade.com/private/account_history?range={date_range}&range_arr[]={custom_range[0]}&range_arr[]={custom_range[1]}&page=1&account={account}&per_page=1000" def cancel_order(): From 6996e5169f7bb24e7dd599279f6c246d69b7dd09 Mon Sep 17 00:00:00 2001 From: maxxrk Date: Fri, 31 Jan 2025 08:06:18 -0600 Subject: [PATCH 33/68] format fixes bump versions --- README.md | 137 +------------------------------------------ firstrade/account.py | 10 +++- setup.py | 6 +- test.py | 7 ++- 4 files changed, 19 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index 417897c..1cf0f00 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 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. @@ -27,7 +27,7 @@ pip install firstrade ## Quikstart -The code below will: +The code in `test.py` will: - Login and print account info. - Get a quote for 'INTC' and print out the information - Place a dry run market order for 'INTC' on the first account in the `account_numbers` list @@ -35,139 +35,6 @@ The code below will: - Contains a cancel order example - Get an option Dates, Quotes, and Greeks - Place a dry run option order - -```python -from firstrade import account, order, symbols - -# Create a session -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) -if len(ft_accounts.account_numbers) < 1: - raise Exception("No accounts found or an error occured exiting...") - -# Print ALL account data -print(ft_accounts.all_accounts) - -# Print 1st account number. -print(ft_accounts.account_numbers[0]) - -# Print ALL accounts market values. -print(ft_accounts.account_balances) - -# Get quote for INTC -quote = symbols.SymbolQuote(ft_ss, ft_accounts.account_numbers[0], "INTC") -print(f"Symbol: {quote.symbol}") -print(f"Tick: {quote.tick}") -print(f"Exchange: {quote.exchange}") -print(f"Bid: {quote.bid}") -print(f"Ask: {quote.ask}") -print(f"Last: {quote.last}") -print(f"Bid Size: {quote.bid_size}") -print(f"Ask Size: {quote.ask_size}") -print(f"Last Size: {quote.last_size}") -print(f"Bid MMID: {quote.bid_mmid}") -print(f"Ask MMID: {quote.ask_mmid}") -print(f"Last MMID: {quote.last_mmid}") -print(f"Change: {quote.change}") -print(f"High: {quote.high}") -print(f"Low: {quote.low}") -print(f"Change Color: {quote.change_color}") -print(f"Volume: {quote.volume}") -print(f"Quote Time: {quote.quote_time}") -print(f"Last Trade Time: {quote.last_trade_time}") -print(f"Real Time: {quote.realtime}") -print(f"Fractional: {quote.is_fractional}") -print(f"Company Name: {quote.company_name}") - -# Get positions and print them out for an account. -positions = ft_accounts.get_positions(account=ft_accounts.account_numbers[1]) -print(positions) -for item in positions["items"]: - print( - f"Quantity {item["quantity"]} of security {item["symbol"]} held in account {ft_accounts.account_numbers[1]}" - ) - -# Get account history (past 200) -history = ft_accounts.get_account_history(account=ft_accounts.account_numbers[0]) -for item in history["items"]: - print(f"Transaction: {item["symbol"]} on {item["report_date"]} for {item["amount"]}.") - - -# Create an order object. -ft_order = order.Order(ft_ss) - -# Place 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.LIMIT, - order_type=order.OrderType.BUY, - duration=order.Duration.DAY, - quantity=1, - dry_run=True, -) - -print(order_conf) - -if "order_id" not in order_conf["result"]: - print("Dry run complete.") - print(order_conf["result"]) -else: - print("Order placed successfully.") - print(f"Order ID: {order_conf["result"]["order_id"]}.") - print(f"Order State: {order_conf["result"]["state"]}.") - -# Cancel placed order -# cancel = ft_accounts.cancel_order(order_conf['result']["order_id"]) -# if cancel["result"]["result"] == "success": - # print("Order cancelled successfully.") -# print(cancel) - -# Check orders -recent_orders = ft_accounts.get_orders(ft_accounts.account_numbers[0]) -print(recent_orders) - -#Get option dates -option_first = symbols.OptionQuote(ft_ss, "INTC") -for item in option_first.option_dates["items"]: - print(f"Expiration Date: {item["exp_date"]} Days Left: {item["day_left"]} Expiration Type: {item["exp_type"]}") - -# Get option quote -option_quote = option_first.get_option_quote("INTC", option_first.option_dates["items"][0]["exp_date"]) -print(option_quote) - -# Get option greeks -option_greeks = option_first.get_greek_options("INTC", option_first.option_dates["items"][0]["exp_date"]) -print(option_greeks) - -print(f"Placing dry option order for {option_quote["items"][0]["opt_symbol"]} with a price of {option_quote["items"][0]["ask"]}.") -print("Symbol readable ticker 'INTC'") - -# Place dry option order -option_order = ft_order.place_option_order( - account=ft_accounts.account_numbers[0], - option_symbol=option_quote["items"][0]["opt_symbol"], - order_type=order.OrderType.BUY_OPTION, - price_type=order.PriceType.MARKET, - duration=order.Duration.DAY, - contracts=1, - dry_run=True, -) - -print(option_order) - -# Delete cookies -ft_ss.delete_cookies() -``` - -`You can also find this code in test.py` - --- ## Implemented Features diff --git a/firstrade/account.py b/firstrade/account.py index 1305c9c..988ad1e 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -335,15 +335,19 @@ def get_account_history(self, account, date_range="ytd", custom_range=None): 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"]. + 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() diff --git a/setup.py b/setup.py index 63216ee..fb9e434 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="firstrade", - version="0.0.31", + version="0.0.32", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,7 +13,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0031.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0032.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "beautifulsoup4", "lxml"], packages=["firstrade"], @@ -27,5 +27,7 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], ) diff --git a/test.py b/test.py index f4db4ae..6184d6b 100644 --- a/test.py +++ b/test.py @@ -55,7 +55,12 @@ ) # Get account history (past 200) -history = ft_accounts.get_account_history(account=ft_accounts.account_numbers[0]) +history = ft_accounts.get_account_history( + account=ft_accounts.account_numbers[0], + date_range="cust", + custom_range=["2024-01-01", "2024-06-30"] +) + for item in history["items"]: print(f"Transaction: {item["symbol"]} on {item["report_date"]} for {item["amount"]}.") From 5864ac02f77cd7edf06afd254af880bd087d9f2e Mon Sep 17 00:00:00 2001 From: maxxrk Date: Fri, 31 Jan 2025 08:07:16 -0600 Subject: [PATCH 34/68] remove whitespaces --- firstrade/account.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index 988ad1e..e9ed0ca 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -335,12 +335,12 @@ def get_account_history(self, account, date_range="ytd", custom_range=None): 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 + 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. + 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: From d9635ad4b98c5b6b0ba9ea987a7fcd2e40ea1b5c Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Fri, 31 Jan 2025 14:09:30 +0000 Subject: [PATCH 35/68] style: format code with Black and isort This commit fixes the style issues introduced in bfcfd0d according to the output from Black and isort. Details: None --- firstrade/account.py | 4 +++- firstrade/urls.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index e9ed0ca..90bf7f7 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -348,7 +348,9 @@ def get_account_history(self, account, date_range="ytd", custom_range=None): """ 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)) + response = self.session.get( + urls.account_history(account, date_range, custom_range) + ) return response.json() def get_orders(self, account): diff --git a/firstrade/urls.py b/firstrade/urls.py index 6545805..e036db3 100644 --- a/firstrade/urls.py +++ b/firstrade/urls.py @@ -23,7 +23,9 @@ def account_balances(account): def account_positions(account): - return f"https://api3x.firstrade.com/private/positions?account={account}&per_page=200" + return ( + f"https://api3x.firstrade.com/private/positions?account={account}&per_page=200" + ) def quote(account, symbol): @@ -69,7 +71,7 @@ def session_headers(): "Accept-Encoding": "gzip", "Connection": "Keep-Alive", "Host": "api3x.firstrade.com", - "User-Agent": "okhttp/4.9.2", + "User-Agent": "okhttp/4.9.2", } return headers From 410e1784ad4ca467ebb7450b680a07469fa038b0 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Fri, 31 Jan 2025 14:10:46 +0000 Subject: [PATCH 36/68] refactor: remove unnecessary whitespace Blank lines should not contain any tabs or spaces. --- firstrade/exceptions.py | 4 ++-- firstrade/order.py | 8 ++++---- firstrade/symbols.py | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/firstrade/exceptions.py b/firstrade/exceptions.py index b0e9e84..cc33d78 100644 --- a/firstrade/exceptions.py +++ b/firstrade/exceptions.py @@ -15,7 +15,7 @@ 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.""" pass @@ -32,7 +32,7 @@ class LoginResponseError(LoginError): 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.""" pass diff --git a/firstrade/order.py b/firstrade/order.py index 528c3bd..eb621e0 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -149,7 +149,7 @@ def place_order( 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 = { "symbol": symbol, "transaction": order_type, @@ -215,13 +215,13 @@ def place_option_order( Returns: dict: A dictionary containing the order confirmation data. """ - + 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, diff --git a/firstrade/symbols.py b/firstrade/symbols.py index 9cc6263..c64af50 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -88,7 +88,7 @@ def __init__(self, ft_session: FTSession, account: str, symbol: str): self.realtime = response.json()["result"]["realtime"] self.nls = response.json()["result"]["nls"] self.shares = response.json()["result"]["shares"] - + class OptionQuote: """ @@ -112,7 +112,7 @@ def __init__(self, ft_session: FTSession, symbol: str): self.ft_session = ft_session self.symbol = symbol self.option_dates = self.get_option_dates(symbol) - + def get_option_dates(self, symbol: str): """ Retrieves the expiration dates for options on a given symbol. @@ -129,7 +129,7 @@ def get_option_dates(self, symbol: str): """ response = self.ft_session.get(url=urls.option_dates(symbol)) return response.json() - + def get_option_quote(self, symbol: str, date: str): """ Retrieves the quote for a given option symbol. @@ -146,7 +146,7 @@ def get_option_quote(self, symbol: str, date: str): """ response = self.ft_session.get(url=urls.option_quotes(symbol, date)) return response.json() - + def get_greek_options(self, symbol: str, exp_date: str): """ Retrieves the greeks for options on a given symbol. From f23aeb5c21c03a094dbaa11fed27b09161df0ae3 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Fri, 31 Jan 2025 14:10:59 +0000 Subject: [PATCH 37/68] style: format code with Black and isort This commit fixes the style issues introduced in 410e178 according to the output from Black and isort. Details: https://github.com/MaxxRK/firstrade-api/pull/51 --- firstrade/exceptions.py | 19 ++++++++++++++++++- firstrade/order.py | 25 ++++++++++++------------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/firstrade/exceptions.py b/firstrade/exceptions.py index cc33d78..57f067c 100644 --- a/firstrade/exceptions.py +++ b/firstrade/exceptions.py @@ -1,44 +1,61 @@ class QuoteError(Exception): """Base class for exceptions in the Quote module.""" + pass + 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.""" + pass + 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}" + 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.""" + pass + 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 eb621e0..6e9050e 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -180,17 +180,17 @@ def place_order( return response.json() def place_option_order( - self, - account: str, - option_symbol: str, - price_type: PriceType, - order_type: OrderType, - contracts: int, - duration: Duration, - stop_price: float = None, - price: float = 0.00, - dry_run: bool = True, - order_instruction: OrderInstructions = "0", + self, + account: str, + option_symbol: str, + price_type: PriceType, + order_type: OrderType, + contracts: int, + duration: Duration, + stop_price: float = None, + price: float = 0.00, + dry_run: bool = True, + order_instruction: OrderInstructions = "0", ): """ Builds and places an option order. @@ -221,7 +221,6 @@ def place_option_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, @@ -233,7 +232,7 @@ def place_option_order( "price_type": price_type, } if price_type in [PriceType.LIMIT, PriceType.STOP_LIMIT]: - data["limit_price"] = price + data["limit_price"] = price if price_type in [PriceType.STOP, PriceType.STOP_LIMIT]: data["stop_price"] = stop_price From c246af68db10e13a6afc755f359a83f1468aec53 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Fri, 31 Jan 2025 14:14:43 +0000 Subject: [PATCH 38/68] refactor: add newline at end of file It is recommended to put a newline at the end of the file. --- test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test.py b/test.py index 6184d6b..b78e644 100644 --- a/test.py +++ b/test.py @@ -129,4 +129,4 @@ print(option_order) # Delete cookies -ft_ss.delete_cookies() \ No newline at end of file +ft_ss.delete_cookies() From c53d39229a1a7a6dc187d6ff9bf2f3416f188492 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Fri, 31 Jan 2025 14:15:43 +0000 Subject: [PATCH 39/68] refactor: remove unnecessary `pass` The `pass` statement used here is not necessary. You can safely remove this. --- firstrade/exceptions.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/firstrade/exceptions.py b/firstrade/exceptions.py index 57f067c..3950acd 100644 --- a/firstrade/exceptions.py +++ b/firstrade/exceptions.py @@ -1,8 +1,6 @@ class QuoteError(Exception): """Base class for exceptions in the Quote module.""" - pass - class QuoteRequestError(QuoteError): """Exception raised for errors in the HTTP request during a Quote.""" @@ -25,8 +23,6 @@ def __init__(self, symbol, error_message): class LoginError(Exception): """Exception raised for errors in the login process.""" - pass - class LoginRequestError(LoginError): """Exception raised for errors in the HTTP request during login.""" @@ -50,8 +46,6 @@ def __init__(self, error_message): class AccountError(Exception): """Base class for exceptions in the Account module.""" - pass - class AccountResponseError(AccountError): """Exception raised for errors in the API response when getting account data.""" From 1587af0ee44335029def3006e5346a5058edbba8 Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Wed, 26 Mar 2025 08:41:19 -0500 Subject: [PATCH 40/68] fix bid ask last --- firstrade/symbols.py | 7 ++++--- setup.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/firstrade/symbols.py b/firstrade/symbols.py index c64af50..e836408 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -55,6 +55,7 @@ def __init__(self, ft_session: FTSession, account: str, symbol: str): """ self.ft_session = ft_session response = self.ft_session.get(url=urls.quote(account, symbol)) + print(response.json()) if response.status_code != 200: raise QuoteRequestError(response.status_code) if response.json().get("error", "") != "": @@ -70,9 +71,9 @@ def __init__(self, ft_session: FTSession, account: str, symbol: str): self.change = response.json()["result"]["change"] self.high = response.json()["result"]["high"] self.low = response.json()["result"]["low"] - self.bid_mmid = response.json()["result"]["bid_mmid:"] - self.ask_mmid = response.json()["result"]["ask_mmid:"] - self.last_mmid = response.json()["result"]["last_mmid:"] + self.bid_mmid = response.json()["result"]["bid_mmid"] + self.ask_mmid = response.json()["result"]["ask_mmid"] + self.last_mmid = response.json()["result"]["last_mmid"] self.last_size = response.json()["result"]["last_size"] self.change_color = response.json()["result"]["change_color"] self.volume = response.json()["result"]["vol"] diff --git a/setup.py b/setup.py index fb9e434..ace7776 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="firstrade", - version="0.0.32", + version="0.0.33", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,7 +13,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0032.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0033.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "beautifulsoup4", "lxml"], packages=["firstrade"], From 4505dc1bbe4f895b52439756465602ddfba4910d Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Wed, 26 Mar 2025 08:42:56 -0500 Subject: [PATCH 41/68] remove print --- firstrade/symbols.py | 1 - 1 file changed, 1 deletion(-) diff --git a/firstrade/symbols.py b/firstrade/symbols.py index e836408..b3b2241 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -55,7 +55,6 @@ def __init__(self, ft_session: FTSession, account: str, symbol: str): """ self.ft_session = ft_session response = self.ft_session.get(url=urls.quote(account, symbol)) - print(response.json()) if response.status_code != 200: raise QuoteRequestError(response.status_code) if response.json().get("error", "") != "": From f3f7247563c1609a61e8fc296c9dd3d8e4d8e6a0 Mon Sep 17 00:00:00 2001 From: Thor Lin Date: Mon, 22 Sep 2025 15:48:21 +0800 Subject: [PATCH 42/68] feat: add get_balance_overview method and fix string formatting in print statements --- firstrade/account.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ test.py | 12 +++++------ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index 90bf7f7..b512518 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -385,3 +385,52 @@ def cancel_order(self, order_id): 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/test.py b/test.py index b78e644..2dba20f 100644 --- a/test.py +++ b/test.py @@ -51,7 +51,7 @@ print(positions) for item in positions["items"]: print( - f"Quantity {item["quantity"]} of security {item["symbol"]} held in account {ft_accounts.account_numbers[1]}" + f"Quantity {item['quantity']} of security {item['symbol']} held in account {ft_accounts.account_numbers[1]}" ) # Get account history (past 200) @@ -62,7 +62,7 @@ ) for item in history["items"]: - print(f"Transaction: {item["symbol"]} on {item["report_date"]} for {item["amount"]}.") + print(f"Transaction: {item['symbol']} on {item['report_date']} for {item['amount']}.") # Create an order object. @@ -86,8 +86,8 @@ print(order_conf["result"]) else: print("Order placed successfully.") - print(f"Order ID: {order_conf["result"]["order_id"]}.") - print(f"Order State: {order_conf["result"]["state"]}.") + print(f"Order ID: {order_conf['result']['order_id']}.") + print(f"Order State: {order_conf['result']['state']}.") # Cancel placed order # cancel = ft_accounts.cancel_order(order_conf['result']["order_id"]) @@ -102,7 +102,7 @@ #Get option dates option_first = symbols.OptionQuote(ft_ss, "INTC") for item in option_first.option_dates["items"]: - print(f"Expiration Date: {item["exp_date"]} Days Left: {item["day_left"]} Expiration Type: {item["exp_type"]}") + print(f"Expiration Date: {item['exp_date']} Days Left: {item['day_left']} Expiration Type: {item['exp_type']}") # Get option quote option_quote = option_first.get_option_quote("INTC", option_first.option_dates["items"][0]["exp_date"]) @@ -112,7 +112,7 @@ option_greeks = option_first.get_greek_options("INTC", option_first.option_dates["items"][0]["exp_date"]) print(option_greeks) -print(f"Placing dry option order for {option_quote["items"][0]["opt_symbol"]} with a price of {option_quote["items"][0]["ask"]}.") +print(f"Placing dry option order for {option_quote['items'][0]['opt_symbol']} with a price of {option_quote['items'][0]['ask']}.") print("Symbol readable ticker 'INTC'") # Place dry option order From d091c9df5ea2846473e620f75e990683f3cd81a9 Mon Sep 17 00:00:00 2001 From: Thor Lin Date: Mon, 13 Oct 2025 14:30:40 +0800 Subject: [PATCH 43/68] fix: add break statements to improve MFA recipient selection logic --- firstrade/account.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/firstrade/account.py b/firstrade/account.py index b512518..fd9bb96 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -244,12 +244,14 @@ def _handle_mfa(self): "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() From 3c7623ef63b6e9da76ff958803813059335f82ff Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 11:04:30 +0000 Subject: [PATCH 44/68] style: format code with Black and isort This commit fixes the style issues introduced in 345e928 according to the output from Black and isort. Details: None --- test.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/test.py b/test.py index 2dba20f..dc1bbc2 100644 --- a/test.py +++ b/test.py @@ -1,7 +1,7 @@ from firstrade import account, order, symbols # Create a session -ft_ss = account.FTSession(username="", password="", email = "", profile_path="") +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: ") @@ -58,11 +58,13 @@ history = ft_accounts.get_account_history( account=ft_accounts.account_numbers[0], date_range="cust", - custom_range=["2024-01-01", "2024-06-30"] + custom_range=["2024-01-01", "2024-06-30"], ) for item in history["items"]: - print(f"Transaction: {item['symbol']} on {item['report_date']} for {item['amount']}.") + print( + f"Transaction: {item['symbol']} on {item['report_date']} for {item['amount']}." + ) # Create an order object. @@ -92,27 +94,35 @@ # Cancel placed order # cancel = ft_accounts.cancel_order(order_conf['result']["order_id"]) # if cancel["result"]["result"] == "success": - # print("Order cancelled successfully.") +# print("Order cancelled successfully.") # print(cancel) # Check orders recent_orders = ft_accounts.get_orders(ft_accounts.account_numbers[0]) print(recent_orders) -#Get option dates +# Get option dates option_first = symbols.OptionQuote(ft_ss, "INTC") for item in option_first.option_dates["items"]: - print(f"Expiration Date: {item['exp_date']} Days Left: {item['day_left']} Expiration Type: {item['exp_type']}") + print( + f"Expiration Date: {item['exp_date']} Days Left: {item['day_left']} Expiration Type: {item['exp_type']}" + ) # Get option quote -option_quote = option_first.get_option_quote("INTC", option_first.option_dates["items"][0]["exp_date"]) +option_quote = option_first.get_option_quote( + "INTC", option_first.option_dates["items"][0]["exp_date"] +) print(option_quote) # Get option greeks -option_greeks = option_first.get_greek_options("INTC", option_first.option_dates["items"][0]["exp_date"]) +option_greeks = option_first.get_greek_options( + "INTC", option_first.option_dates["items"][0]["exp_date"] +) print(option_greeks) -print(f"Placing dry option order for {option_quote['items'][0]['opt_symbol']} with a price of {option_quote['items'][0]['ask']}.") +print( + f"Placing dry option order for {option_quote['items'][0]['opt_symbol']} with a price of {option_quote['items'][0]['ask']}." +) print("Symbol readable ticker 'INTC'") # Place dry option order From 8584f718110f1231e84a624dc995b5d68b3d0bf9 Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Mon, 13 Oct 2025 06:08:04 -0500 Subject: [PATCH 45/68] bump version --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ace7776..6727c2d 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="firstrade", - version="0.0.33", + version="0.0.34", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,7 +13,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0033.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0034.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "beautifulsoup4", "lxml"], packages=["firstrade"], From fb8e60eb512b68a789512fe11704effef0b0123a Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Mon, 26 Jan 2026 19:29:32 -0600 Subject: [PATCH 46/68] update requires --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6727c2d..cb065a1 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ url="https://github.com/MaxxRK/firstrade-api", download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0034.tar.gz", keywords=["FIRSTRADE", "API"], - install_requires=["requests", "beautifulsoup4", "lxml"], + install_requires=["requests", "pyotp"], packages=["firstrade"], classifiers=[ "Development Status :: 3 - Alpha", From 172bb1a454226ea9b0c964ed3b3f988f3ddb1c06 Mon Sep 17 00:00:00 2001 From: Jacques Boscq Date: Tue, 3 Feb 2026 06:20:22 +0100 Subject: [PATCH 47/68] Handle missing MFA method in login process Raise error if no valid MFA method is provided during login. --- firstrade/account.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/firstrade/account.py b/firstrade/account.py index fd9bb96..6a0edb7 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -261,6 +261,9 @@ def _handle_mfa(self): "t_token": self.t_token, } response = self.session.post(urls.verify_pin(), data=data) + else: + raise LoginResponseError("MFA required but no valid MFA method " + "was provided (pin, email/phone, or mfa_secret).") self.login_json = response.json() if self.login_json["error"] == "": if self.pin or self.mfa_secret is not None: From 259e531198d0c4ce4527a741c8138c3e2b24c6ea Mon Sep 17 00:00:00 2001 From: Jacques Boscq Date: Tue, 3 Feb 2026 06:32:38 +0100 Subject: [PATCH 48/68] Fix indentation for raising LoginResponseError $ flake8 --select=E128 /home/da/.local/lib/python3.12/site-packages/firstrade/account.py --- firstrade/account.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index 6a0edb7..d16e688 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -262,8 +262,10 @@ def _handle_mfa(self): } response = self.session.post(urls.verify_pin(), data=data) else: - raise LoginResponseError("MFA required but no valid MFA method " - "was provided (pin, email/phone, or mfa_secret).") + raise LoginResponseError( + "MFA required but no valid MFA method " + "was provided (pin, email/phone, or mfa_secret)." + ) self.login_json = response.json() if self.login_json["error"] == "": if self.pin or self.mfa_secret is not None: From a7e306d343d02b20e81e69d4ce8dee4e1fafaa4c Mon Sep 17 00:00:00 2001 From: Jacques Boscq Date: Tue, 3 Feb 2026 06:48:19 +0100 Subject: [PATCH 49/68] Use a more appropriate exception for MFA login failure --- firstrade/account.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/firstrade/account.py b/firstrade/account.py index d16e688..111f16d 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -8,6 +8,7 @@ from firstrade import urls from firstrade.exceptions import ( AccountResponseError, + LoginError, LoginRequestError, LoginResponseError, ) @@ -262,7 +263,7 @@ def _handle_mfa(self): } response = self.session.post(urls.verify_pin(), data=data) else: - raise LoginResponseError( + raise LoginError( "MFA required but no valid MFA method " "was provided (pin, email/phone, or mfa_secret)." ) From de0e0cddcad651eb5259f04b391148fda6769491 Mon Sep 17 00:00:00 2001 From: Jacques Boscq Date: Tue, 3 Feb 2026 07:12:17 +0100 Subject: [PATCH 50/68] Change account number for fetching positions Assume only one account exists --- test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test.py b/test.py index dc1bbc2..91242aa 100644 --- a/test.py +++ b/test.py @@ -47,11 +47,11 @@ print(f"Company Name: {quote.company_name}") # Get positions and print them out for an account. -positions = ft_accounts.get_positions(account=ft_accounts.account_numbers[1]) +positions = ft_accounts.get_positions(account=ft_accounts.account_numbers[0]) print(positions) for item in positions["items"]: print( - f"Quantity {item['quantity']} of security {item['symbol']} held in account {ft_accounts.account_numbers[1]}" + f"Quantity {item['quantity']} of security {item['symbol']} held in account {ft_accounts.account_numbers[0]}" ) # Get account history (past 200) From 16bd1ace5880e016fe0817e07fefe962422b2661 Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Tue, 3 Feb 2026 06:58:06 -0600 Subject: [PATCH 51/68] bump version --- setup.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index cb065a1..33d20b9 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="firstrade", - version="0.0.34", + version="0.0.35", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,7 +13,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0034.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0035.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "pyotp"], packages=["firstrade"], @@ -23,8 +23,6 @@ "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", From 2b53d083274cc58d6393bc2e67cacff29d84439e Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Tue, 3 Feb 2026 07:28:45 -0600 Subject: [PATCH 52/68] add styles pyproject --- pyproject.toml | 20 ++++++++++++++++++++ styles/ruff.toml | 15 +++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 pyproject.toml create mode 100644 styles/ruff.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6f0c8b2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[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 +] +[tool.ruff.lint.per-file-ignores] +"src/brokerages/*" = [ + "PLR0913", # Allow too many arguments + "PLR1702", # Allow many nested blocks +] 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) +] From c77a5589b51b1f8a15c9322b32f3c939db5e33f2 Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Tue, 3 Feb 2026 10:23:21 -0600 Subject: [PATCH 53/68] nuke deepsource refactor --- .deepsource.toml | 20 --- .gitignore | 138 +-------------- firstrade/account.py | 380 ++++++++++++++++++++-------------------- firstrade/exceptions.py | 7 +- firstrade/order.py | 80 ++++----- firstrade/symbols.py | 98 +++++------ firstrade/urls.py | 60 ++++--- pyproject.toml | 3 - test.py | 27 +-- 9 files changed, 330 insertions(+), 483 deletions(-) delete mode 100644 .deepsource.toml 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 9d45d97..f514b74 100644 --- a/.gitignore +++ b/.gitignore @@ -1,136 +1,2 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class -testme.py -*.pkl -_*.py - -# 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/firstrade/account.py b/firstrade/account.py index 111f16d..c038948 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -1,6 +1,5 @@ import json -import os -import pickle +from pathlib import Path import pyotp import requests @@ -15,8 +14,7 @@ class FTSession: - """ - Class creating a session for Firstrade. + """Class creating a session for Firstrade. 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. @@ -51,20 +49,20 @@ class FTSession: 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. + username: str, + password: str, + pin: str = "", + email: str = "", + phone: str = "", + mfa_secret: str = "", + profile_path: str | None = None, + ) -> None: + """Initialize a new instance of the FTSession class. Args: username (str): Firstrade login username. @@ -73,22 +71,22 @@ def __init__( 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.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.t_token: str | None = None + self.otp_options: list[dict[str, str]] | None = None + self.login_json: dict[str, str] = {} self.session = requests.Session() - def login(self): - """ - Validates and logs into the Firstrade platform. + 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. @@ -96,43 +94,41 @@ def login(self): 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.update(urls.session_headers()) + ftat: str = self._load_cookies() + if not ftat: self.session.headers["ftat"] = ftat - response = self.session.get(url="https://api3x.firstrade.com/", timeout=10) + response: requests.Response = self.session.get(url="https://api3x.firstrade.com/", timeout=10) self.session.headers["access-token"] = urls.access_token() - data = { + data: dict[str, str] = { "username": r"" + self.username, "password": r"" + self.password, } - response = self.session.post( + response: requests.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 ( - "mfa" not in self.login_json - and "ftat" in self.login_json - and self.login_json["error"] == "" - ): + 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 = self.login_json.get("t_token") - if self.mfa_secret is None: - self.otp_options = self.login_json.get("otp") + self.t_token: str | None = self.login_json.get("t_token") + if not self.mfa_secret: + self.otp_options: str | None = self.login_json.get("otp") if response.status_code != 200: raise LoginRequestError(response.status_code) - if self.login_json["error"] != "": + if self.login_json["error"]: raise LoginResponseError(self.login_json["error"]) - need_code = self._handle_mfa() - if 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 @@ -141,149 +137,148 @@ def login(self): self._save_cookies() return False - def login_two(self, code): - """Method to finish login to the Firstrade platform.""" - data = { + def login_two(self, code: str) -> None: + """Finish login to the Firstrade platform.""" + data: dict[str, str | None] = { "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"] != "": + response: requests.Response = self.session.post(urls.verify_pin(), data=data) + self.login_json: dict[str, str] = response.json() + if not 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 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 _load_cookies(self): - """ - Checks if session cookies were saved. + def _load_cookies(self) -> str: + """Check if session cookies were saved. Returns: str: The saved session token. - """ + """ 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) - - 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: - ftat = pickle.load(f) + 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): - """Saves session cookies to a file.""" + def _save_cookies(self) -> str | None: + """Save 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") + directory = Path(self.profile_path) + if not directory.exists(): + directory.mkdir(parents=True) + path: Path = directory / f"ft_cookies{self.username}.json" else: - path = f"ft_cookies{self.username}.pkl" - with open(path, "wb") as f: - ftat = self.session.headers.get("ftat") - pickle.dump(ftat, f) + 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): - """ - Masks the email for use in the API. + def _mask_email(email: str) -> str: + """Mask 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 + local, domain = email.split(sep="@") + masked_local: str = local[0] + "*" * 4 domain_name, tld = domain.split(".") - masked_domain = domain_name[0] + "*" * 4 + masked_domain: str = domain_name[0] + "*" * 4 return f"{masked_local}@{masked_domain}.{tld}" - def _handle_mfa(self): - """ - Handles multi-factor authentication. + def _handle_mfa(self) -> bool: + """Handle 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) + response: requests.Response | None = None + data: dict[str, str | None] = {} + + if self.pin: + response: requests.Response = self._handle_pin_mfa(data) + elif (self.email or self.phone) and not self.mfa_secret: + response: requests.Response = self._handle_otp_mfa(data) + elif self.mfa_secret: + response: requests.Response = self._handle_secret_mfa(data) else: - raise LoginError( - "MFA required but no valid MFA method " - "was provided (pin, email/phone, or mfa_secret)." - ) + error_msg = "MFA required but no valid MFA method was provided (pin, email/phone, or mfa_secret)." + raise LoginError(error_msg) + 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 + if self.login_json["error"]: + raise LoginResponseError(self.login_json["error"]) - def __getattr__(self, name): - """ - Forwards unknown attribute access to session object. + if self.pin or self.mfa_secret: + self.session.headers["sid"] = self.login_json["sid"] + return False + 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.session.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.session.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.session.post(urls.verify_pin(), data=data) + + 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) @@ -291,21 +286,20 @@ def __getattr__(self, name): class FTAccountData: """Dataclass for storing account information.""" - def __init__(self, session): - """ - Initializes a new instance of the FTAccountData class. + 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. + session (requests.Session): The session object used for making HTTP requests. + """ - self.session = session - self.all_accounts = [] - self.account_numbers = [] - self.account_balances = {} - response = self.session.get(url=urls.user_info()) - self.user_info = response.json() - response = self.session.get(urls.account_list()) + 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.get(url=urls.user_info()) + self.user_info: dict[str, object] = response.json() + response: requests.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() @@ -313,80 +307,84 @@ def __init__(self, session): 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. + def get_account_balances(self, account: str) -> dict[str, object]: + """Get 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)) + response: requests.Response = self.session.get(urls.account_balances(account)) return response.json() - def get_positions(self, account): - """Gets currently held positions for a given account. + 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: 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. + 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. - range (str): The range of the history. Defaults to "ytd". + date_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. + 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 is required when date_range is 'cust'.") - response = self.session.get( - urls.account_history(account, date_range, custom_range) + raise ValueError("Custom range required.") + response: requests.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. + def get_orders(self, account: str) -> list[dict[str, object]]: + """Retrieve 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. + 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 = { "order_id": order_id, } @@ -394,9 +392,8 @@ def cancel_order(self, order_id): 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. + 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 @@ -410,6 +407,7 @@ def get_balance_overview(self, account, keywords=None): 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 = [ @@ -423,22 +421,22 @@ def get_balance_overview(self, account, keywords=None): "margin", ] - payload = self.get_account_balances(account) + payload: dict[str, object] = self.get_account_balances(account) - filtered = {} + filtered: dict[str, object] = {} - def _walk(node, path): + def _walk(node: object, path: list[str]) -> None: if isinstance(node, dict): for k, v in node.items(): - _walk(v, path + [str(k)]) + _walk(node=v, path=[*path, str(object=k)]) elif isinstance(node, list): - for i, v in enumerate(node): - _walk(v, path + [str(i)]) + for i, v in enumerate(iterable=node): + _walk(node=v, path=[*path, str(object=i)]) else: - key_path = ".".join(path) - low = key_path.lower() + key_path: str = ".".join(path) + low: str = key_path.lower() if any(sub in low for sub in keywords): filtered[key_path] = node - _walk(payload, []) + _walk(node=payload, path=[]) return filtered diff --git a/firstrade/exceptions.py b/firstrade/exceptions.py index 3950acd..4e31cc8 100644 --- a/firstrade/exceptions.py +++ b/firstrade/exceptions.py @@ -36,10 +36,9 @@ def __init__(self, status_code, message="Error in HTTP request during login"): 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}" - ) + 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) diff --git a/firstrade/order.py b/firstrade/order.py index 6e9050e..d2c062c 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -1,12 +1,11 @@ -from enum import Enum +import enum from firstrade import urls from firstrade.account import FTSession -class PriceType(str, Enum): - """ - Enum for valid price types in 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. @@ -15,6 +14,7 @@ class PriceType(str, Enum): 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" @@ -25,9 +25,8 @@ class PriceType(str, Enum): TRAILING_STOP_PERCENT = "6" -class Duration(str, Enum): - """ - Enum for valid order durations. +class Duration(enum.StrEnum): + """Enum for valid order durations. Attributes: DAY (str): Day order. @@ -35,6 +34,7 @@ class Duration(str, Enum): PRE_MARKET (str): Pre-market order. AFTER_MARKET (str): After-market order. DAY_EXT (str): Day extended order. + """ DAY = "0" @@ -44,9 +44,8 @@ class Duration(str, Enum): DAY_EXT = "D" -class OrderType(str, Enum): - """ - Enum for valid order types. +class OrderType(enum.StrEnum): + """Enum for valid order types. Attributes: BUY (str): Buy order. @@ -55,6 +54,7 @@ class OrderType(str, Enum): BUY_TO_COVER (str): Buy to cover order. BUY_OPTION (str): Buy option order. SELL_OPTION (str): Sell option order. + """ BUY = "B" @@ -65,28 +65,30 @@ class OrderType(str, Enum): SELL_OPTION = "SO" -class OrderInstructions(str, Enum): - """ - Enum for valid order instructions. +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(str, Enum): - """ - Enum for valid option types. +class OptionType(enum.StrEnum): + """Enum for valid option types. Attributes: CALL (str): Call option. PUT (str): Put option. + """ CALL = "C" @@ -94,15 +96,16 @@ class OptionType(str, Enum): class Order: - """ - Represents an order with methods to place it. + """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 + def __init__(self, ft_session: FTSession) -> None: + """Initialize the Order with a FirstTrade session.""" + self.ft_session: FTSession = ft_session def place_order( self, @@ -113,13 +116,13 @@ def place_order( duration: Duration, quantity: int = 0, price: float = 0.00, - stop_price: float = None, + stop_price: float | None = None, + *, dry_run: bool = True, notional: bool = False, - order_instruction: OrderInstructions = "0", + order_instruction: OrderInstructions = OrderInstructions.NONE, ): - """ - Builds and places an order. + """Build and place an order. Args: account (str): The account number to place the order in. @@ -134,15 +137,10 @@ def place_order( 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: dict: A dictionary containing the order confirmation data. - """ + """ if price_type == PriceType.MARKET and not notional: price = "" if order_instruction == OrderInstructions.AON and price_type != PriceType.LIMIT: @@ -164,11 +162,11 @@ def place_order( if notional: data["dollar_amount"] = price del data["shares"] - if price_type in [PriceType.LIMIT, PriceType.STOP_LIMIT]: + if price_type in {PriceType.LIMIT, PriceType.STOP_LIMIT}: data["limit_price"] = price - if price_type in [PriceType.STOP, PriceType.STOP_LIMIT]: + if price_type in {PriceType.STOP, PriceType.STOP_LIMIT}: data["stop_price"] = stop_price - response = self.ft_session.post(url=urls.order(), data=data) + response: requests.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() @@ -187,13 +185,13 @@ def place_option_order( order_type: OrderType, contracts: int, duration: Duration, - stop_price: float = None, + stop_price: float | None = None, price: float = 0.00, + *, dry_run: bool = True, - order_instruction: OrderInstructions = "0", + order_instruction: OrderInstructions = OrderInstructions.NONE, ): - """ - Builds and places an option order. + """Build and place an option order. Args: account (str): The account number to place the order in. @@ -209,13 +207,11 @@ def place_option_order( Raises: ValueError: If AON orders are not limit orders or if AON orders have a quantity of 100 contracts or less. - PreviewOrderError: If there is an error during the preview of the order. - PlaceOrderError: If there is an error during the placement of the order. Returns: dict: A dictionary containing the order confirmation data. - """ + """ 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: @@ -231,9 +227,9 @@ def place_option_order( "account": account, "price_type": price_type, } - if price_type in [PriceType.LIMIT, PriceType.STOP_LIMIT]: + if price_type in {PriceType.LIMIT, PriceType.STOP_LIMIT}: data["limit_price"] = price - if price_type in [PriceType.STOP, PriceType.STOP_LIMIT]: + if price_type in {PriceType.STOP, PriceType.STOP_LIMIT}: data["stop_price"] = stop_price response = self.ft_session.post(url=urls.option_order(), data=data) diff --git a/firstrade/symbols.py b/firstrade/symbols.py index b3b2241..dee567e 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -1,11 +1,12 @@ +from typing import Any + from firstrade import urls from firstrade.account import FTSession from firstrade.exceptions import QuoteRequestError, QuoteResponseError class SymbolQuote: - """ - Data class representing a stock quote for a given 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. @@ -38,11 +39,11 @@ class SymbolQuote: 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, account: str, symbol: str): - """ - Initializes a new instance of the SymbolQuote class. + """Initialize a new instance of the SymbolQuote class. Args: ft_session (FTSession): The session object used for making HTTP requests to Firstrade. @@ -52,70 +53,70 @@ def __init__(self, ft_session: FTSession, account: str, symbol: str): Raises: QuoteRequestError: If the quote request fails with a non-200 status code. QuoteResponseError: If the quote response contains an error message. + """ - self.ft_session = ft_session + self.ft_session: FTSession = ft_session response = self.ft_session.get(url=urls.quote(account, symbol)) if response.status_code != 200: raise QuoteRequestError(response.status_code) - if response.json().get("error", "") != "": + if response.json().get("error", ""): raise QuoteResponseError(symbol, response.json()["error"]) - self.symbol = response.json()["result"]["symbol"] - self.sec_type = response.json()["result"]["sec_type"] - self.tick = response.json()["result"]["tick"] - self.bid = response.json()["result"]["bid"] - self.bid_size = response.json()["result"]["bid_size"] - self.ask = response.json()["result"]["ask"] - self.ask_size = response.json()["result"]["ask_size"] - self.last = response.json()["result"]["last"] - self.change = response.json()["result"]["change"] - self.high = response.json()["result"]["high"] - self.low = response.json()["result"]["low"] - self.bid_mmid = response.json()["result"]["bid_mmid"] - self.ask_mmid = response.json()["result"]["ask_mmid"] - self.last_mmid = response.json()["result"]["last_mmid"] - self.last_size = response.json()["result"]["last_size"] - self.change_color = response.json()["result"]["change_color"] - self.volume = response.json()["result"]["vol"] - self.today_close = response.json()["result"]["today_close"] - self.open = response.json()["result"]["open"] - self.quote_time = response.json()["result"]["quote_time"] - self.last_trade_time = response.json()["result"]["last_trade_time"] - self.company_name = response.json()["result"]["company_name"] - self.exchange = response.json()["result"]["exchange"] - self.has_option = response.json()["result"]["has_option"] - self.is_etf = bool(response.json()["result"]["is_etf"]) + self.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 = response.json()["result"]["realtime"] - self.nls = response.json()["result"]["nls"] - self.shares = response.json()["result"]["shares"] + 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. + """Data class representing an option quote for a given symbol. Attributes: ft_session (FTSession): The session object used for making HTTP requests to Firstrade. symbol (str): The symbol for which the option quote information is retrieved. option_dates (dict): A dict of expiration dates for options on the given symbol. + """ def __init__(self, ft_session: FTSession, symbol: str): - """ - Initializes a new instance of the OptionQuote class. + """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 option quote information is retrieved. + """ self.ft_session = ft_session self.symbol = symbol self.option_dates = self.get_option_dates(symbol) def get_option_dates(self, symbol: str): - """ - Retrieves the expiration dates for options on a given symbol. + """Retrieve the expiration dates for options on a given symbol. Args: symbol (str): The symbol for which the expiration dates are retrieved. @@ -123,16 +124,12 @@ def get_option_dates(self, symbol: str): Returns: dict: A dict of expiration dates and other information for options on the given symbol. - Raises: - QuoteRequestError: If the request for option dates fails with a non-200 status code. - QuoteResponseError: If the response for option dates contains an error message. """ response = self.ft_session.get(url=urls.option_dates(symbol)) return response.json() - def get_option_quote(self, symbol: str, date: str): - """ - Retrieves the quote for a given option symbol. + 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. @@ -140,16 +137,12 @@ def get_option_quote(self, symbol: str, date: str): Returns: dict: A dictionary containing the quote and other information for the given option symbol. - Raises: - QuoteRequestError: If the request for the option quote fails with a non-200 status code. - QuoteResponseError: If the response for the option quote contains an error message. """ response = self.ft_session.get(url=urls.option_quotes(symbol, date)) return response.json() def get_greek_options(self, symbol: str, exp_date: str): - """ - Retrieves the greeks for options on a given symbol. + """Retrieve the greeks for options on a given symbol. Args: symbol (str): The symbol for which the greeks are retrieved. @@ -158,9 +151,6 @@ def get_greek_options(self, symbol: str, exp_date: str): Returns: dict: A dictionary containing the greeks for the options on the given symbol. - Raises: - QuoteRequestError: If the request for the greeks fails with a non-200 status code. - QuoteResponseError: If the response for the greeks contains an error message. """ data = { "type": "chain", diff --git a/firstrade/urls.py b/firstrade/urls.py index e036db3..c34d2f7 100644 --- a/firstrade/urls.py +++ b/firstrade/urls.py @@ -1,73 +1,88 @@ -def login(): +def login() -> str: + """Login URL for FirstTrade API.""" return "https://api3x.firstrade.com/sess/login" -def request_code(): +def request_code() -> str: + """Request PIN/MFA option for FirstTrade API.""" return "https://api3x.firstrade.com/sess/request_code" -def verify_pin(): +def verify_pin() -> str: + """Request PIN/MFA verification for FirstTrade API.""" return "https://api3x.firstrade.com/sess/verify_pin" -def user_info(): +def user_info() -> str: + """Retrieve user information URL for FirstTrade API.""" return "https://api3x.firstrade.com/private/userinfo" -def account_list(): +def account_list() -> str: + """Retrieve account list URL for FirstTrade API.""" return "https://api3x.firstrade.com/private/acct_list" -def account_balances(account): +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_positions(account): - return ( - f"https://api3x.firstrade.com/private/positions?account={account}&per_page=200" - ) +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 quote(account, symbol): +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 order(): +def order() -> str: + """Place equity order URL for FirstTrade API.""" return "https://api3x.firstrade.com/private/stock_order" -def order_list(account): +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, date_range, custom_range): +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(): +def cancel_order() -> str: + """Cancel placed order URL for FirstTrade API.""" return "https://api3x.firstrade.com/private/cancel_order" -def option_dates(symbol): +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, date): +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(): +def greek_options() -> str: + """Greek options analytical data URL for FirstTrade API.""" return "https://api3x.firstrade.com/private/greekoptions/analytical" -def option_order(): +def option_order() -> str: + """Place option order URL for FirstTrade API.""" return "https://api3x.firstrade.com/private/option_order" -def session_headers(): - headers = { +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", @@ -76,5 +91,6 @@ def session_headers(): return headers -def access_token(): +def access_token() -> str: + """Access token for FirstTrade API.""" return "833w3XuIFycv18ybi" diff --git a/pyproject.toml b/pyproject.toml index 6f0c8b2..0224cbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,9 +12,6 @@ ignore = [ "TRY301", # Allow raising exceptions "DOC201", # Don't require returns in doc "DOC501", # Don't require raises in doc -] -[tool.ruff.lint.per-file-ignores] -"src/brokerages/*" = [ "PLR0913", # Allow too many arguments "PLR1702", # Allow many nested blocks ] diff --git a/test.py b/test.py index 91242aa..1a2defb 100644 --- a/test.py +++ b/test.py @@ -51,7 +51,7 @@ print(positions) for item in positions["items"]: print( - f"Quantity {item['quantity']} of security {item['symbol']} held in account {ft_accounts.account_numbers[0]}" + f"Quantity {item['quantity']} of security {item['symbol']} held in account {ft_accounts.account_numbers[0]}", ) # Get account history (past 200) @@ -63,7 +63,7 @@ for item in history["items"]: print( - f"Transaction: {item['symbol']} on {item['report_date']} for {item['amount']}." + f"Transaction: {item['symbol']} on {item['report_date']} for {item['amount']}.", ) @@ -83,7 +83,9 @@ print(order_conf) -if "order_id" not in order_conf["result"]: +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.") print(order_conf["result"]) else: @@ -92,10 +94,11 @@ print(f"Order State: {order_conf['result']['state']}.") # Cancel placed order -# cancel = ft_accounts.cancel_order(order_conf['result']["order_id"]) -# if cancel["result"]["result"] == "success": -# print("Order cancelled successfully.") -# print(cancel) +if not order_conf.get("error"): + cancel = ft_accounts.cancel_order(order_conf["result"]["order_id"]) + if cancel["result"]["result"] == "success": + print("Order cancelled successfully.") + print(cancel) # Check orders recent_orders = ft_accounts.get_orders(ft_accounts.account_numbers[0]) @@ -105,23 +108,25 @@ option_first = symbols.OptionQuote(ft_ss, "INTC") for item in option_first.option_dates["items"]: print( - f"Expiration Date: {item['exp_date']} Days Left: {item['day_left']} Expiration Type: {item['exp_type']}" + f"Expiration Date: {item['exp_date']} Days Left: {item['day_left']} Expiration Type: {item['exp_type']}", ) # Get option quote option_quote = option_first.get_option_quote( - "INTC", option_first.option_dates["items"][0]["exp_date"] + "INTC", + option_first.option_dates["items"][0]["exp_date"], ) print(option_quote) # Get option greeks option_greeks = option_first.get_greek_options( - "INTC", option_first.option_dates["items"][0]["exp_date"] + "INTC", + option_first.option_dates["items"][0]["exp_date"], ) print(option_greeks) print( - f"Placing dry option order for {option_quote['items'][0]['opt_symbol']} with a price of {option_quote['items'][0]['ask']}." + f"Placing dry option order for {option_quote['items'][0]['opt_symbol']} with a price of {option_quote['items'][0]['ask']}.", ) print("Symbol readable ticker 'INTC'") From 4936b35d976af77445f520c7120769f4658d04a0 Mon Sep 17 00:00:00 2001 From: Amodio Date: Tue, 3 Feb 2026 19:53:43 +0100 Subject: [PATCH 54/68] Add HTTP logging with the debug attribute passed to FTSession. --- firstrade/account.py | 87 +++++++++++++++++++++++++++++++++++--------- firstrade/order.py | 8 ++-- firstrade/symbols.py | 8 ++-- 3 files changed, 78 insertions(+), 25 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index c038948..428a9ff 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -2,6 +2,7 @@ from pathlib import Path import pyotp +import logging import requests from firstrade import urls @@ -12,6 +13,7 @@ LoginResponseError, ) +logger = logging.getLogger(__name__) class FTSession: """Class creating a session for Firstrade. @@ -22,18 +24,19 @@ class FTSession: Attributes: username (str): Firstrade login username. password (str): Firstrade login password. - pin (str): Firstrade login pin. + 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): + __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. @@ -49,6 +52,8 @@ class FTSession: Masks the email for use in the API. _handle_mfa(): Handles multi-factor authentication. + _request(method, url, **kwargs): + HTTP requests wrapper to the API. """ @@ -61,16 +66,19 @@ def __init__( phone: str = "", mfa_secret: str = "", profile_path: str | None = None, + 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. + 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 pkl file. + debug (bool, optional): Log HTTP requests/responses if true. DO NOT POST YOUR LOGS ONLINE. """ self.username: str = username @@ -80,6 +88,15 @@ def __init__( self.phone: str = phone self.mfa_secret: str = mfa_secret self.profile_path: str | None = profile_path + 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: list[dict[str, str]] | None = None self.login_json: dict[str, str] = {} @@ -100,7 +117,7 @@ def login(self) -> bool: ftat: str = self._load_cookies() if not ftat: self.session.headers["ftat"] = ftat - response: requests.Response = self.session.get(url="https://api3x.firstrade.com/", timeout=10) + response: requests.Response = self._request("get", url="https://api3x.firstrade.com/", timeout=10) self.session.headers["access-token"] = urls.access_token() data: dict[str, str] = { @@ -108,7 +125,8 @@ def login(self) -> bool: "password": r"" + self.password, } - response: requests.Response = self.session.post( + response: requests.Response = self._request( + "post", url=urls.login(), data=data, ) @@ -145,7 +163,7 @@ def login_two(self, code: str) -> None: "remember_for": "30", "t_token": self.t_token, } - response: requests.Response = self.session.post(urls.verify_pin(), data=data) + response: requests.Response = self._request("post", urls.verify_pin(), data=data) self.login_json: dict[str, str] = response.json() if not self.login_json["error"]: raise LoginResponseError(self.login_json["error"]) @@ -242,7 +260,7 @@ def _handle_pin_mfa(self, data: dict[str, str | None]) -> requests.Response: "remember_for": "30", "t_token": self.t_token, }) - return self.session.post(urls.verify_pin(), data=data) + 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.""" @@ -258,7 +276,7 @@ def _handle_otp_mfa(self, data: dict[str, str | None]) -> requests.Response: }) break - return self.session.post(urls.request_code(), data=data) + 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.""" @@ -268,7 +286,42 @@ def _handle_secret_mfa(self, data: dict[str, str | None]) -> requests.Response: "remember_for": "30", "t_token": self.t_token, }) - return self.session.post(urls.verify_pin(), data=data) + return self._request("post", urls.verify_pin(), data=data) + + def _request(self, method, url, **kwargs): + """Send HTTP request and log the full response content if debug=True.""" + resp = self.session.request(method, url, **kwargs) + + 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. @@ -297,9 +350,9 @@ def __init__(self, session: requests.Session) -> None: self.all_accounts: list[dict[str, object]] = [] self.account_numbers: list[str] = [] self.account_balances: dict[str, object] = {} - response: requests.Response = self.session.get(url=urls.user_info()) + response: requests.Response = self.session._request("get", url=urls.user_info()) self.user_info: dict[str, object] = response.json() - response: requests.Response = self.session.get(urls.account_list()) + 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() @@ -317,7 +370,7 @@ def get_account_balances(self, account: str) -> dict[str, object]: dict: Dict of the response from the API. """ - response: requests.Response = self.session.get(urls.account_balances(account)) + response: requests.Response = self.session._request("get", urls.account_balances(account)) return response.json() def get_positions(self, account: str) -> dict[str, object]: @@ -330,7 +383,7 @@ def get_positions(self, account: str) -> dict[str, object]: dict: Dict of the response from the API. """ - response = self.session.get(urls.account_positions(account)) + response = self.session._request("get", urls.account_positions(account)) return response.json() def get_account_history( @@ -357,8 +410,8 @@ def get_account_history( """ if date_range == "cust" and custom_range is None: raise ValueError("Custom range required.") - response: requests.Response = self.session.get( - urls.account_history(account, date_range, custom_range), + response: requests.Response = self.session._request( + "get", urls.account_history(account, date_range, custom_range), ) return response.json() @@ -372,7 +425,7 @@ def get_orders(self, account: str) -> list[dict[str, object]]: list: A list of dictionaries, each containing details about an order. """ - response = self.session.get(url=urls.order_list(account)) + response = self.session._request("get", url=urls.order_list(account)) return response.json() def cancel_order(self, order_id: str) -> dict[str, object]: @@ -389,7 +442,7 @@ def cancel_order(self, order_id: str) -> dict[str, object]: "order_id": order_id, } - response = self.session.post(url=urls.cancel_order(), data=data) + 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]: diff --git a/firstrade/order.py b/firstrade/order.py index d2c062c..951d413 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -166,7 +166,7 @@ def place_order( data["limit_price"] = price if price_type in {PriceType.STOP, PriceType.STOP_LIMIT}: data["stop_price"] = stop_price - response: requests.Response = self.ft_session.post(url=urls.order(), data=data) + 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() @@ -174,7 +174,7 @@ def place_order( return preview_data data["preview"] = "false" data["stage"] = "P" - response = self.ft_session.post(url=urls.order(), data=data) + response = self.ft_session._request("post", url=urls.order(), data=data) return response.json() def place_option_order( @@ -232,11 +232,11 @@ def place_option_order( if price_type in {PriceType.STOP, PriceType.STOP_LIMIT}: data["stop_price"] = stop_price - response = self.ft_session.post(url=urls.option_order(), data=data) + 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.post(url=urls.option_order(), data=data) + 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 dee567e..56f52fa 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -56,7 +56,7 @@ def __init__(self, ft_session: FTSession, account: str, symbol: str): """ self.ft_session: FTSession = ft_session - response = self.ft_session.get(url=urls.quote(account, symbol)) + 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", ""): @@ -125,7 +125,7 @@ def get_option_dates(self, symbol: str): dict: A dict of expiration dates and other information for options on the given symbol. """ - response = self.ft_session.get(url=urls.option_dates(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]: @@ -138,7 +138,7 @@ def get_option_quote(self, symbol: str, date: str) -> dict[Any, Any]: dict: A dictionary containing the quote and other information for the given option symbol. """ - response = self.ft_session.get(url=urls.option_quotes(symbol, date)) + 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): @@ -158,5 +158,5 @@ def get_greek_options(self, symbol: str, exp_date: str): "root_symbol": symbol, "exp_date": exp_date, } - response = self.ft_session.post(url=urls.greek_options(), data=data) + response = self.ft_session._request("post", url=urls.greek_options(), data=data) return response.json() From 696507373ea1db629430052d09cbafdef87f3cc8 Mon Sep 17 00:00:00 2001 From: Amodio Date: Tue, 3 Feb 2026 20:28:26 +0100 Subject: [PATCH 55/68] Fix a bug while loading/restoring the session cookie from last commit. --- firstrade/account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firstrade/account.py b/firstrade/account.py index c038948..f011a1a 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -98,7 +98,7 @@ def login(self) -> bool: """ self.session.headers.update(urls.session_headers()) ftat: str = self._load_cookies() - if not ftat: + if ftat: self.session.headers["ftat"] = ftat response: requests.Response = self.session.get(url="https://api3x.firstrade.com/", timeout=10) self.session.headers["access-token"] = urls.access_token() From d9cff6950aca50f41faf178b26872d19dac81da4 Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Tue, 3 Feb 2026 20:09:57 -0600 Subject: [PATCH 56/68] bump version --- setup.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 33d20b9..94e735f 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.35", + version="0.0.36", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -13,7 +14,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0035.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0036.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "pyotp"], packages=["firstrade"], From e2a3dde8d34757a715a3731b862f3dfae9e54558 Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Tue, 3 Feb 2026 20:13:05 -0600 Subject: [PATCH 57/68] bump version --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 94e735f..cf8a7eb 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setuptools.setup( name="firstrade", - version="0.0.36", + version="0.0.37", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -14,7 +14,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0036.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0037.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "pyotp"], packages=["firstrade"], From 5f4e1bc333ed6cf2e89a11d6be1e21556920cd5a Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Tue, 3 Feb 2026 21:21:46 -0600 Subject: [PATCH 58/68] allow mfa code input more robust login --- firstrade/account.py | 57 +++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index 02d5673..339a261 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -15,6 +15,7 @@ logger = logging.getLogger(__name__) + class FTSession: """Class creating a session for Firstrade. @@ -57,17 +58,7 @@ class FTSession: """ - def __init__( - self, - username: str, - password: str, - pin: str = "", - email: str = "", - phone: str = "", - mfa_secret: str = "", - profile_path: str | None = None, - debug: bool = False - ) -> None: + def __init__(self, username: str, password: str, pin: str = "", email: str = "", phone: str = "", mfa_secret: str = "", profile_path: str | None = None, debug: bool = False) -> None: """Initialize a new instance of the FTSession class. Args: @@ -93,6 +84,7 @@ def __init__( 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) @@ -126,7 +118,7 @@ def login(self) -> bool: } response: requests.Response = self._request( - "post", + method="post", url=urls.login(), data=data, ) @@ -139,7 +131,7 @@ def login(self) -> bool: self.session.headers["sid"] = self.login_json["sid"] return False self.t_token: str | None = self.login_json.get("t_token") - if not self.mfa_secret: + if not self.login_json.get("mfa"): self.otp_options: str | None = self.login_json.get("otp") if response.status_code != 200: raise LoginRequestError(response.status_code) @@ -157,15 +149,23 @@ def login(self) -> bool: def login_two(self, code: str) -> None: """Finish login to the Firstrade platform.""" - 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("post", urls.verify_pin(), data=data) + 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 not self.login_json["error"]: + 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"] @@ -232,24 +232,29 @@ def _handle_mfa(self) -> bool: """ response: requests.Response | None = None data: dict[str, str | None] = {} - if self.pin: response: requests.Response = self._handle_pin_mfa(data) - elif (self.email or self.phone) and not self.mfa_secret: + 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) - self.login_json = response.json() 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 @@ -310,6 +315,7 @@ def _request(self, method, url, **kwargs): # 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) @@ -411,7 +417,8 @@ def get_account_history( 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), + "get", + urls.account_history(account, date_range, custom_range), ) return response.json() From 819d7caf20ef31c1deafd5d7b0aa4d7b09427282 Mon Sep 17 00:00:00 2001 From: Amodio Date: Wed, 4 Feb 2026 23:40:20 +0100 Subject: [PATCH 59/68] Several improvements to test.py: - explanation to using mfa_secret - detail of what is printed with some indentation - history transactions limited to last december - fix missing price for the dry run order: Error placing order: Bad Request : Wrong order price Reference code: 1062 - fix cancellation of dry_run order leading to an error: Traceback (most recent call last): File "/home/da/./test.py", line 104, in cancel = ft_accounts.cancel_order(order_conf["result"]["order_id"]) ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^ KeyError: 'order_id' - dry_run/preview option was not setting the price despite the preceding text, it was using a MARKET type of price, leading to an error if not executed in market hours: Option Market orders are not accepted outside of regular market hour. Please enter a limit order. --- test.py | 138 ++++++++++++++++++++++++++++++-------------------------- 1 file changed, 74 insertions(+), 64 deletions(-) diff --git a/test.py b/test.py index 1a2defb..5b52552 100644 --- a/test.py +++ b/test.py @@ -1,7 +1,13 @@ +#!/usr/bin/python3 + from firstrade import account, order, symbols +import json # Create a session -ft_ss = account.FTSession(username="", password="", email="", profile_path="") +# 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 +ft_ss = account.FTSession(username="", password="", mfa_secret="") +#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: ") @@ -13,64 +19,64 @@ 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, ft_accounts.account_numbers[0], "INTC") -print(f"Symbol: {quote.symbol}") -print(f"Tick: {quote.tick}") -print(f"Exchange: {quote.exchange}") -print(f"Bid: {quote.bid}") -print(f"Ask: {quote.ask}") -print(f"Last: {quote.last}") -print(f"Bid Size: {quote.bid_size}") -print(f"Ask Size: {quote.ask_size}") -print(f"Last Size: {quote.last_size}") -print(f"Bid MMID: {quote.bid_mmid}") -print(f"Ask MMID: {quote.ask_mmid}") -print(f"Last MMID: {quote.last_mmid}") -print(f"Change: {quote.change}") -print(f"High: {quote.high}") -print(f"Low: {quote.low}") -print(f"Change Color: {quote.change_color}") -print(f"Volume: {quote.volume}") -print(f"Quote Time: {quote.quote_time}") -print(f"Last Trade Time: {quote.last_trade_time}") -print(f"Real Time: {quote.realtime}") -print(f"Fractional: {quote.is_fractional}") -print(f"Company Name: {quote.company_name}") +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[0]) -print(positions) +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"Quantity {item['quantity']} of security {item['symbol']} held in account {ft_accounts.account_numbers[0]}", - ) + print(f"\t{item['quantity']}\tof security {item['symbol']}.") -# Get account history (past 200) +# 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=["2024-01-01", "2024-06-30"], + custom_range=["2025-12-01", "2025-12-31"], ) -for item in history["items"]: - print( - f"Transaction: {item['symbol']} on {item['report_date']} for {item['amount']}.", - ) - +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 dry run order and print out order confirmation data. +# Place a dry run order and print out order confirmation data. order_conf = ft_order.place_order( ft_accounts.account_numbers[0], symbol="INTC", @@ -78,37 +84,38 @@ order_type=order.OrderType.BUY, duration=order.Duration.DAY, quantity=1, + price=3.37, dry_run=True, ) - -print(order_conf) +print(f"Preview of an order to buy 1 share of INTC: {json.dumps(order_conf, indent=2)}") 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.") - print(order_conf["result"]) + print(f"Dry run complete!") else: - print("Order placed successfully.") - print(f"Order ID: {order_conf['result']['order_id']}.") - print(f"Order State: {order_conf['result']['state']}.") + print("Order placed successfully!") + print(f"\tOrder ID: {order_conf['result']['order_id']}.") + print(f"\tOrder State: {order_conf['result']['state']}.") -# Cancel placed order -if not order_conf.get("error"): +# 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("Order cancelled successfully.") - print(cancel) + print(f"Order cancelled successfully: {cancel}.") + else: + print(f"Cannot cancel order: {cancel}.") # Check orders recent_orders = ft_accounts.get_orders(ft_accounts.account_numbers[0]) -print(recent_orders) +print(f"Recent orders: {json.dumps(recent_orders, indent=2)}") -# Get option dates +# 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"Expiration Date: {item['exp_date']} Days Left: {item['day_left']} Expiration Type: {item['exp_type']}", + f"\tExpiration Date: {item['exp_date']} Days Left: {item['day_left']} Expiration Type: {item['exp_type']}", ) # Get option quote @@ -116,32 +123,35 @@ "INTC", option_first.option_dates["items"][0]["exp_date"], ) -print(option_quote) +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"], ) -print(option_greeks) - -print( - f"Placing dry option order for {option_quote['items'][0]['opt_symbol']} with a price of {option_quote['items'][0]['ask']}.", -) -print("Symbol readable ticker 'INTC'") +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.MARKET, + 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)}") -print(option_order) - -# Delete cookies -ft_ss.delete_cookies() +# Delete the session cookie +#ft_ss.delete_cookies() From f3f9eba31d9d40d184d6af4bc91d8d71d7a24447 Mon Sep 17 00:00:00 2001 From: Amodio Date: Thu, 5 Feb 2026 00:07:19 +0100 Subject: [PATCH 60/68] Made the test.py script executable --- test.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 test.py diff --git a/test.py b/test.py old mode 100644 new mode 100755 From 30652b9d26c969168da96e0ea39f8df3e2d44107 Mon Sep 17 00:00:00 2001 From: Amodio Date: Thu, 5 Feb 2026 19:54:12 +0100 Subject: [PATCH 61/68] PRE_MARKET&AFTER_MARKET orders have been replaced by OVERNIGHT: `The selected duration is no longer available. Please update your app to the latest version to access the new extended hours features. Reference code: 1567` --- firstrade/order.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/firstrade/order.py b/firstrade/order.py index 951d413..dd334fd 100644 --- a/firstrade/order.py +++ b/firstrade/order.py @@ -29,19 +29,17 @@ class Duration(enum.StrEnum): """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 (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(enum.StrEnum): From 08a71827bc1bc1cf1f57a30544ad492d34a189fa Mon Sep 17 00:00:00 2001 From: Amodio Date: Thu, 5 Feb 2026 21:28:12 +0100 Subject: [PATCH 62/68] Add OHLC data retrieval to fix #64 --- README.md | 2 + firstrade/symbols.py | 92 +++++++++++++++++++++++++++++++++++++++++++- firstrade/urls.py | 8 ++++ test.py | 5 +++ 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1cf0f00..b01991b 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The code in `test.py` will: - Place a dry run market order for 'INTC' on the first account in the `account_numbers` list - Print out the order confirmation - Contains a cancel order example +- Get OHLC data - Get an option Dates, Quotes, and Greeks - Place a dry run option order --- @@ -41,6 +42,7 @@ The code in `test.py` will: - [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 diff --git a/firstrade/symbols.py b/firstrade/symbols.py index 56f52fa..35a19a9 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, List, Tuple, Dict, Optional from firstrade import urls from firstrade.account import FTSession @@ -160,3 +160,93 @@ def get_greek_options(self, symbol: str, exp_date: str): } 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_), + ) + + 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 c34d2f7..98f1404 100644 --- a/firstrade/urls.py +++ b/firstrade/urls.py @@ -38,6 +38,14 @@ def quote(account: str, symbol: str) -> str: return f"https://api3x.firstrade.com/public/quote?account={account}&q={symbol}" +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 order() -> str: """Place equity order URL for FirstTrade API.""" return "https://api3x.firstrade.com/private/stock_order" diff --git a/test.py b/test.py index 5b52552..d1f0b42 100755 --- a/test.py +++ b/test.py @@ -106,6 +106,11 @@ 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 recent_orders = ft_accounts.get_orders(ft_accounts.account_numbers[0]) print(f"Recent orders: {json.dumps(recent_orders, indent=2)}") From f0d892b83e085c346e56dc0c2d2f9529eab51432 Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Thu, 5 Feb 2026 14:44:05 -0600 Subject: [PATCH 63/68] bump version --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index cf8a7eb..84c9763 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setuptools.setup( name="firstrade", - version="0.0.37", + version="0.0.38", author="MaxxRK", author_email="maxxrk@pm.me", description="An unofficial API for Firstrade", @@ -14,7 +14,7 @@ long_description_content_type="text/markdown", license="MIT", url="https://github.com/MaxxRK/firstrade-api", - download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0037.tar.gz", + download_url="https://github.com/MaxxRK/firstrade-api/archive/refs/tags/0038.tar.gz", keywords=["FIRSTRADE", "API"], install_requires=["requests", "pyotp"], packages=["firstrade"], From e3556b82fe4e8f9fdd48ee2475537f94dc5fb978 Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Thu, 5 Feb 2026 15:01:19 -0600 Subject: [PATCH 64/68] import and code formatting --- firstrade/symbols.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/firstrade/symbols.py b/firstrade/symbols.py index 35a19a9..2131e36 100644 --- a/firstrade/symbols.py +++ b/firstrade/symbols.py @@ -1,4 +1,4 @@ -from typing import Any, List, Tuple, Dict, Optional +from typing import Any from firstrade import urls from firstrade.account import FTSession @@ -161,9 +161,9 @@ def get_greek_options(self, symbol: str, exp_date: str): 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. + """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 @@ -176,6 +176,7 @@ class SymbolOHLC: 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"): @@ -192,6 +193,7 @@ def __init__(self, ft_session: FTSession, symbol: str, range_: str = "1d"): status code. QuoteResponseError: If the OHLC response contains an error message. + """ self.ft_session = ft_session self.symbol: str = symbol @@ -215,9 +217,7 @@ def __init__(self, ft_session: FTSession, symbol: str, range_: str = "1d"): self.ohlc_raw: list = result["ohlc"] self.vol_raw: list = result.get("vol", []) - self.candles: List[ - Tuple[int, float, float, float, float, int] - ] = [] + self.candles: List[Tuple[int, float, float, float, float, int]] = [] self._parse_ohlc_and_volume() @@ -230,9 +230,7 @@ def _parse_ohlc_and_volume(self) -> None: 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 - } + 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] @@ -248,5 +246,5 @@ def _parse_ohlc_and_volume(self) -> None: raise KeyError(f"Missing volume for timestamp {timestamp}") self.candles.append( - (timestamp, open_, high, low, close, volume) + (timestamp, open_, high, low, close, volume), ) From 09ff9143b63f59b1ba2ca6c296535e020ed1fb63 Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Thu, 5 Feb 2026 21:52:37 -0600 Subject: [PATCH 65/68] allow external login token save session flag --- firstrade/account.py | 25 +++++++++++++++++-------- test.py | 18 ++++++++++-------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index 339a261..bd5da57 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -1,3 +1,5 @@ +from turtle import save +from requests.sessions import session import json from pathlib import Path @@ -58,7 +60,7 @@ class FTSession: """ - def __init__(self, username: str, password: str, pin: str = "", email: str = "", phone: str = "", mfa_secret: str = "", profile_path: str | None = None, debug: bool = False) -> None: + 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: @@ -68,7 +70,8 @@ def __init__(self, username: str, password: str, pin: str = "", email: str = "", 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 pkl file. + 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. """ @@ -79,6 +82,7 @@ def __init__(self, username: str, password: str, pin: str = "", email: str = "", 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) @@ -94,7 +98,7 @@ def __init__(self, username: str, password: str, pin: str = "", email: str = "", self.login_json: dict[str, str] = {} self.session = requests.Session() - def login(self) -> bool: + def login(self, session_token: str = "") -> bool: """Validate and log into the Firstrade platform. This method sets up the session headers, loads cookies if available, and performs the login request. @@ -106,7 +110,8 @@ def login(self) -> bool: """ self.session.headers.update(urls.session_headers()) - ftat: str = self._load_cookies() + # Allow providing "ftat" token from an external source + ftat: str = session_token or self._load_cookies() if ftat: self.session.headers["ftat"] = ftat response: requests.Response = self._request("get", url="https://api3x.firstrade.com/", timeout=10) @@ -144,7 +149,8 @@ def login(self) -> bool: return True self.session.headers["ftat"] = self.login_json["ftat"] self.session.headers["sid"] = self.login_json["sid"] - self._save_cookies() + if self.save_session: + self._save_cookies() return False def login_two(self, code: str) -> None: @@ -169,7 +175,8 @@ def login_two(self, code: str) -> None: 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() + if self.save_session: + self._save_cookies() def delete_cookies(self) -> None: """Delete the session cookies.""" @@ -179,7 +186,8 @@ def delete_cookies(self) -> None: def _load_cookies(self) -> str: """Check if session cookies were saved. - Returns: + Returns + ------- str: The saved session token. """ @@ -196,7 +204,8 @@ def _load_cookies(self) -> str: def _save_cookies(self) -> str | None: """Save session cookies to a file.""" - if self.profile_path is not None: + # Allow providing "ftat" token from an external source + if self.profile_path is not None and not self.session_token: directory = Path(self.profile_path) if not directory.exists(): directory.mkdir(parents=True) diff --git a/test.py b/test.py index d1f0b42..ddf5f6f 100755 --- a/test.py +++ b/test.py @@ -1,13 +1,15 @@ #!/usr/bin/python3 -from firstrade import account, order, symbols import json +from firstrade import account, order, symbols + # Create a session # 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 -ft_ss = account.FTSession(username="", password="", mfa_secret="") -#ft_ss = account.FTSession(username="", password="", email="", profile_path="") +# 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: ") @@ -92,7 +94,7 @@ 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(f"Dry run complete!") + print("Dry run complete!") else: print("Order placed successfully!") print(f"\tOrder ID: {order_conf['result']['order_id']}.") @@ -130,7 +132,7 @@ ) limited_option_quote = { **option_quote, - "items": option_quote["items"][:2] + "items": option_quote["items"][:2], } print(f"Option quote for INTC (limited to the first two items): {json.dumps(limited_option_quote, indent=2)}") @@ -141,9 +143,9 @@ ) limited_option_greeks = { **option_greeks, - "chains": option_greeks["chains"][:2] + "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)}") +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( @@ -159,4 +161,4 @@ print(f"Preview of an option order for {option_quote['items'][0]['opt_symbol']}: {json.dumps(option_order, indent=2)}") # Delete the session cookie -#ft_ss.delete_cookies() +# ft_ss.delete_cookies() From 0ec0eb3123285bfe42be1bd8ab2202ce29e93d27 Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Thu, 5 Feb 2026 21:58:04 -0600 Subject: [PATCH 66/68] remove bad imports --- firstrade/account.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index bd5da57..20fdafb 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -1,5 +1,3 @@ -from turtle import save -from requests.sessions import session import json from pathlib import Path From 4d4874f1d501e01f5af12f467411740dafd66eff Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Sat, 7 Feb 2026 18:54:51 -0600 Subject: [PATCH 67/68] revert external token add get tokens method --- firstrade/account.py | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index 20fdafb..d9385d4 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -96,7 +96,7 @@ def __init__(self, username: str, password: str, pin: str = "", email: str = "", self.login_json: dict[str, str] = {} self.session = requests.Session() - def login(self, session_token: str = "") -> bool: + 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. @@ -108,8 +108,7 @@ def login(self, session_token: str = "") -> bool: """ self.session.headers.update(urls.session_headers()) - # Allow providing "ftat" token from an external source - ftat: str = session_token or self._load_cookies() + 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) @@ -181,6 +180,17 @@ def delete_cookies(self) -> None: 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 _load_cookies(self) -> str: """Check if session cookies were saved. @@ -203,16 +213,17 @@ def _load_cookies(self) -> str: def _save_cookies(self) -> str | None: """Save session cookies to a file.""" # Allow providing "ftat" token from an external source - if self.profile_path is not None and not self.session_token: - 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) + 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: @@ -348,7 +359,6 @@ def __getattr__(self, name: str) -> object: """ return getattr(self.session, name) - class FTAccountData: """Dataclass for storing account information.""" From 67c5af32bd950434a95552e6b17312ef9b754f37 Mon Sep 17 00:00:00 2001 From: MaxxRK Date: Sat, 7 Feb 2026 19:47:23 -0600 Subject: [PATCH 68/68] allow session build with tokens --- firstrade/account.py | 47 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/firstrade/account.py b/firstrade/account.py index d9385d4..38b74b9 100644 --- a/firstrade/account.py +++ b/firstrade/account.py @@ -58,7 +58,19 @@ class FTSession: """ - 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: + 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: @@ -92,7 +104,7 @@ def __init__(self, username: str, password: str, pin: str = "", email: str = "", logging.getLogger("requests.packages.urllib3").setLevel(logging.DEBUG) logging.getLogger("requests.packages.urllib3").propagate = True self.t_token: str | None = None - self.otp_options: list[dict[str, str]] | None = None + self.otp_options: str | list[dict[str, str]] | None = None self.login_json: dict[str, str] = {} self.session = requests.Session() @@ -111,7 +123,7 @@ def login(self) -> bool: 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) + 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] = { @@ -134,7 +146,7 @@ def login(self) -> bool: return False self.t_token: str | None = self.login_json.get("t_token") if not self.login_json.get("mfa"): - self.otp_options: str | None = self.login_json.get("otp") + self.otp_options = self.login_json.get("otp") if response.status_code != 200: raise LoginRequestError(response.status_code) if self.login_json["error"]: @@ -191,7 +203,25 @@ def get_tokens(self) -> dict[str, str | bytes | dict[str, str] | None]: "cookies": cookies or "", } - def _load_cookies(self) -> str: + 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 @@ -311,9 +341,9 @@ def _handle_secret_mfa(self, data: dict[str, str | None]) -> requests.Response: }) return self._request("post", urls.verify_pin(), data=data) - def _request(self, method, url, **kwargs): + 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) + 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 @@ -359,6 +389,7 @@ def __getattr__(self, name: str) -> object: """ return getattr(self.session, name) + class FTAccountData: """Dataclass for storing account information.""" @@ -376,7 +407,7 @@ def __init__(self, session: requests.Session) -> None: 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"] != "": + 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"]: