From dd9b151d51870424dd3facd73ff301b10fa83091 Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Tue, 2 Apr 2024 11:24:13 -0700 Subject: [PATCH 01/18] Added necessary elements to enable rebalancing. Cleaned up most PyCharm project warnings. --- .gitignore | 3 + schwab_api/account_information.py | 7 +- schwab_api/authentication.py | 38 +-- schwab_api/schwab.py | 403 +++++++++++++++++++++--------- schwab_api/totp_generator.py | 1 + schwab_api/urls.py | 39 ++- 6 files changed, 347 insertions(+), 144 deletions(-) diff --git a/.gitignore b/.gitignore index ea8b159..09703b5 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,9 @@ dmypy.json # Pyre type checker .pyre/ +# PyCharm +.idea/ + user_data_dir auth.json *.png diff --git a/schwab_api/account_information.py b/schwab_api/account_information.py index 7256095..6294597 100644 --- a/schwab_api/account_information.py +++ b/schwab_api/account_information.py @@ -1,5 +1,6 @@ class Account(dict): def __init__(self, account_id, positions, market_value, available_cash, account_value, cost_basis): + super().__init__() self.account_id = account_id self.positions = positions self.market_value = market_value @@ -19,12 +20,14 @@ def _as_dict(self): def __repr__(self) -> str: return str(self._as_dict()) - + def __str__(self) -> str: return str(self._as_dict()) + class Position(dict): def __init__(self, symbol, description, quantity, cost, market_value, security_id): + super().__init__() self.symbol = symbol self.description = description self.quantity = quantity @@ -46,4 +49,4 @@ def __repr__(self) -> str: return str(self._as_dict()) def __str__(self) -> str: - return str(self._as_dict()) \ No newline at end of file + return str(self._as_dict()) diff --git a/schwab_api/authentication.py b/schwab_api/authentication.py index 3f57c67..22d9126 100644 --- a/schwab_api/authentication.py +++ b/schwab_api/authentication.py @@ -8,16 +8,18 @@ from playwright_stealth import stealth_async from requests.cookies import cookiejar_from_dict - # Constants USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{version}) Gecko/20100101 Firefox/" -VIEWPORT = { 'width': 1920, 'height': 1080 } +VIEWPORT = {'width': 1920, 'height': 1080} + class SessionManager: - def __init__(self) -> None: + def __init__(self, browser_type="firefox", headless=False) -> None: """ - This class is using asynchonous playwright mode. + This class is using asynchronous playwright mode. """ + self.browserType = browser_type + self.headless = headless self.headers = None self.session = requests.Session() self.playwright = None @@ -32,7 +34,7 @@ def check_auth(self): def get_session(self): return self.session - + def login(self, username, password, totp_secret=None): """ This function will log the user into schwab using asynchronous Playwright and saving the authentication cookies in the session header. @@ -43,7 +45,7 @@ def login(self, username, password, totp_secret=None): :param password: The password for the schwab account/ :type totp_secret: Optional[str] - :param totp_secret: The TOTP secret used to complete multi-factor authentication + :param totp_secret: The TOTP secret used to complete multi-factor authentication through Symantec VIP. If this isn't given, sign in will use SMS. :rtype: boolean @@ -52,7 +54,7 @@ def login(self, username, password, totp_secret=None): """ result = asyncio.run(self._async_login(username, password, totp_secret)) return result - + async def _async_login(self, username, password, totp_secret=None): """ This function runs in async mode to perform login. Use with login function. See login function for details. @@ -71,10 +73,10 @@ async def _async_login(self, username, password, totp_secret=None): viewport=VIEWPORT ) await stealth_async(self.page) - - await self.page.goto("https://www.schwab.com/") - await self.page.route(re.compile(r".*balancespositions*"), self._asyncCaptureAuthToken) + await self.page.goto(urls.homepage()) + + await self.page.route(re.compile(r".*balancespositions*"), self._async_capture_auth_token) login_frame = "schwablmslogin" await self.page.wait_for_selector("#" + login_frame) @@ -93,22 +95,30 @@ async def _async_login(self, username, password, totp_secret=None): try: await self.page.frame(name=login_frame).press("[placeholder=\"Password\"]", "Enter") - await self.page.wait_for_url(re.compile(r"app/trade"), wait_until="domcontentloaded") # Making it more robust than specifying an exact url which may change. + # Making it more robust than specifying an exact url which may change. + await self.page.wait_for_url(re.compile(r"app/trade"), + wait_until="domcontentloaded") except TimeoutError: raise Exception("Login was not successful; please check username and password") await self.page.wait_for_selector("#_txtSymbol") - await self._async_save_and_close_session() + await self._async_save_session() return True async def _async_save_and_close_session(self): + await self._async_save_session() + await self.async_close_session() + + async def _async_save_session(self): cookies = {cookie["name"]: cookie["value"] for cookie in await self.page.context.cookies()} self.session.cookies = cookiejar_from_dict(cookies) + + async def async_close_session(self): await self.page.close() await self.browser.close() await self.playwright.stop() - - async def _asyncCaptureAuthToken(self, route): + + async def _async_capture_auth_token(self, route): self.headers = await route.request.all_headers() await route.continue_() diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index 145d4d0..3a2a4c5 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -1,21 +1,22 @@ import json import urllib.parse import requests -import sys +from re import sub from . import urls from .account_information import Position, Account from .authentication import SessionManager + class Schwab(SessionManager): def __init__(self, **kwargs): """ The Schwab class. Used to interact with schwab. """ - self.headless = kwargs.get("headless", True) - self.browserType = kwargs.get("browserType", "firefox") - super(Schwab, self).__init__() + headless = kwargs.get("headless", True) + browser_type = kwargs.get("browser_type", "firefox") + super(Schwab, self).__init__(headless=headless, browser_type=browser_type) def get_account_info(self): """ @@ -54,7 +55,7 @@ def get_account_info(self): )._as_dict() ) - if not "ChildOptionPositions" in position: + if "ChildOptionPositions" not in position: continue # Add call positions if they exist @@ -118,44 +119,49 @@ def get_transaction_history_v2(self, account_id): return [r.text], False return json.loads(r.text) - def trade(self, ticker, side, qty, account_id, dry_run=True): + def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_optimized_cost_basis=True): """ ticker (Str) - The symbol you want to trade, side (str) - Either 'Buy' or 'Sell', - qty (int) - The amount of shares to buy/sell, + qty (float) - The amount of shares to buy/sell, account_id (int) - The account ID to place the trade on. If the ID is XXXX-XXXX, - we're looking for just XXXXXXXX. + we're looking for just XXXXXXXX, + reinvest (boolean) - Reinvest dividends, + tax_optimized_cost_basis (boolean) - Use tax optimized cost-basis strategy, as opposed to FIFO. Returns messages (list of strings), is_success (boolean) """ if side == "Buy": - buySellCode = 1 + buy_sell_code = 1 elif side == "Sell": - buySellCode = 2 + buy_sell_code = 2 else: raise Exception("side must be either Buy or Sell") - data = { - "IsMinQty":False, - "CustomerId":str(account_id), - "BuySellCode":buySellCode, - "Quantity":str(qty), - "IsReinvestDividends":False, - "SecurityId":ticker, - "TimeInForce":"1", # Day Only - "OrderType":1, # Market Order - "CblMethod":"FIFO", - "CblDefault":"FIFO", - "CostBasis":"FIFO", - } - - r = self.session.post(urls.order_verification(), data) + cost_basis_method = 'BTAX' if tax_optimized_cost_basis else 'FIFO' - if r.status_code != 200: - return [r.text], False + payload = { + "IsMinQty": False, + "CustomerId": str(account_id), + "BuySellCode": buy_sell_code, + "Quantity": str(qty), + "SecurityId": ticker, + "TimeInForce": "1", # Day Only + "OrderType": 1, # Market Order + "CblMethod": cost_basis_method, + "CblDefault": cost_basis_method, + "CostBasis": cost_basis_method, + } + if side == "Buy": + payload["IsReinvestDividends"] = reinvest - response = json.loads(r.text) + reply = self.session.post(urls.order_verification(), payload) + + if reply.status_code != 200: + return [reply.text], False + + response = json.loads(reply.text) messages = list() for message in response["Messages"]: @@ -164,13 +170,13 @@ def trade(self, ticker, side, qty, account_id, dry_run=True): if dry_run: return messages, True - data = { + payload = { "AccountId": str(account_id), "ActionType": side, "ActionTypeText": side, "BuyAction": side == "Buy", - "CostBasis": "FIFO", - "CostBasisMethod": "FIFO", + "CostBasis": cost_basis_method, + "CostBasisMethod": cost_basis_method, "IsMarketHours": True, "ItemIssueId": int(response['IssueId']), "NetAmount": response['NetAmount'], @@ -183,34 +189,181 @@ def trade(self, ticker, side, qty, account_id, dry_run=True): "Timing": "Day Only" } - r = self.session.post(urls.order_confirmation(), data) + reply = self.session.post(urls.order_confirmation(), payload) - if r.status_code != 200: - messages.append(r.text) + if reply.status_code != 200: + messages.append(reply.text) return messages, False - response = json.loads(r.text) + response = json.loads(reply.text) + for message in response["Messages"]: + messages.append(message["Message"]) + if response["ReturnCode"] == 0: return messages, True return messages, False + def get_current_price(self, ticker): + with self.page.expect_navigation(): + self.page.goto(urls.trade_page()) + + self.page.click("#_txtSymbol") + self.page.fill("#_txtSymbol", ticker) + self.page.press("#_txtSymbol", "Enter") + self.page.press("#_txtSymbol", "Enter") + + self.page.wait_for_selector("//span[@class='mcaio--last-price ']") + money = self.page.text_content("//span[@class='mcaio--last-price ']") + price = float(sub(r'[^\d.]', '', money)) + return price + + def get_available_funds(self, account_id): + with self.page.expect_navigation(): + self.page.goto(urls.trade_page()) + + account_id_string = str(account_id) + button_label = "Account ending in %c %c %c" % (account_id_string[-3], account_id_string[-2], + account_id_string[-1]) + self.page.locator("#basic-example-small").click() + self.page.locator("//li[@class='sdps-account-selector__list-item']").get_by_label(button_label).click() + self.page.wait_for_timeout(1960) + self.page.wait_for_selector("//span[@class='mcaio-balances_total']") + money = self.page.text_content("//span[@class='mcaio-balances_total']") + funds = float(sub(r'[^\d.]', '', money)) + return funds + + def rebalance(self, account_id: int, allocations: {}, set_aside=0.0, reinvest=True, tax_optimized_cost_basis=True, + dry_run=True) -> ([str], True): + account_info = self.get_account_info() + result_messages = [] + account = account_info[account_id] + portfolio_value = account['account_value'] - set_aside + current_shares = {} + for position in account['positions']: + current_shares[position['symbol']] = position['quantity'] + + total_allocation = 0.0 + for symbol in allocations: + if allocations[symbol] < 0.0: + raise ValueError("Rebalance allocation for %s is less than zero." % symbol) + total_allocation += allocations[symbol] + + desired_shares = {} + prices = {} + lowest_price = 9999999.99 + cheapest = None + for symbol in allocations: + prices[symbol] = self.get_current_price(symbol) + if prices[symbol] < lowest_price: + lowest_price = prices[symbol] + cheapest = symbol + allocation = allocations[symbol] / total_allocation + desired_shares[symbol] = portfolio_value * allocation / prices[symbol] + if symbol in current_shares: + desired_shares[symbol] -= current_shares[symbol] + del current_shares[symbol] + desired_shares[symbol] = round(desired_shares[symbol]) + + for symbol in current_shares: + messages, success = self.sell(account_id, symbol, current_shares[symbol], + tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) + result_messages += messages + if not success: + return result_messages, success + + for symbol in set(desired_shares.keys()): + if desired_shares[symbol] <= 0: + if desired_shares[symbol] < 0: + messages, success = self.sell(account_id, symbol, -desired_shares[symbol], + tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) + result_messages += messages + if not success: + return result_messages, success + del desired_shares[symbol] + + purchase_order = sorted(desired_shares.items(), key=lambda i: prices[i[0]], reverse=True) + for item in purchase_order[:-1]: + messages, success = self.purchase(account_id, item[0], item[1], set_aside, reinvest=reinvest, + tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) + result_messages += messages + if not success: + return result_messages, success + + messages, success = self.purchase(account_id, purchase_order[-1][0], None, set_aside, reinvest=reinvest, + tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) + result_messages += messages + + # If enough cash leftover, purchase the cheapest shares + if cheapest != purchase_order[-1][0]: + messages, success = self.purchase(account_id, cheapest, None, set_aside, reinvest=reinvest, + tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) + result_messages += messages + + return result_messages, success + + def sell(self, account_id, ticker, shares, tax_optimized_cost_basis=True, dry_run=True): + result_messages = ["--> Selling from account %s: %f shares of %s" % (account_id, shares, ticker)] + if shares <= 0: + return result_messages + ["Attempt to sell invalid number of shares: %f" % shares], shares == 0 + messages, success = self.trade( + ticker=ticker, + side="Sell", + qty=shares, + account_id=account_id, + tax_optimized_cost_basis=tax_optimized_cost_basis, + dry_run=dry_run + ) + result_messages += messages + return result_messages, success + + def purchase(self, account_id, ticker, shares=None, set_aside=0.00, reinvest=True, tax_optimized_cost_basis=True, + dry_run=True): + result_intro_message = "<-- Purchase request for account %s of %s" % (account_id, ticker) + if shares is None: + result_intro_message += ", as many shares as possible" + else: + result_intro_message += ", %d shares" % shares + if set_aside > 0.00: + result_intro_message += ", with $.2f set aside" % set_aside + result_messages = [result_intro_message] + funds = self.get_available_funds(account_id) - set_aside + result_messages.append("Funds available in account %s: $%.2f" % (account_id, funds)) + current_price = self.get_current_price(ticker) + potential_shares = funds // current_price + if shares is None or potential_shares < shares: + shares = potential_shares + if shares <= 0: + return result_messages + ["Attempt to purchase invalid number of shares: %d" % shares], shares == 0 + result_messages.append("Purchasing in account %s: %d shares of %s" % (account_id, shares, ticker)) + messages, success = self.trade( + ticker=ticker, + side="Buy", + qty=shares, + account_id=account_id, + tax_optimized_cost_basis=tax_optimized_cost_basis, + reinvest=reinvest, + dry_run=dry_run + ) + result_messages += messages + return result_messages, success + def trade_v2(self, - ticker, - side, - qty, - account_id, - dry_run=True, - # The Fields below are experimental fields that should only be changed if you know what you're doing. - order_type=49, - duration=48, - limit_price=0, - stop_price=0, - primary_security_type=46, - valid_return_codes = {0,10}, - affirm_order=False, - costBasis='FIFO' - ): + ticker, + side, + qty, + account_id, + dry_run=True, + # The Fields below are experimental fields that should only be changed if you know what you're doing. + order_type=49, + duration=48, + limit_price=0, + stop_price=0, + primary_security_type=46, + valid_return_codes=None, + affirm_order=False, + cost_basis='FIFO' + ): """ ticker (Str) - The symbol you want to trade, side (str) - Either 'Buy' or 'Sell', @@ -274,16 +427,19 @@ def trade_v2(self, 'LIFO': Last In First Out 'BTAX': Tax Lot Optimizer ('VSP': Specific Lots -> just for reference. Not implemented: Requires to select lots manually.) - Note: this function calls the new Schwab API, which is flakier and seems to have stricter authentication requirements. + Note: this function calls the new Schwab API, which is flakier and seems to have stricter authentication + requirements. For now, only use this function if the regular trade function doesn't work for your use case. Returns messages (list of strings), is_success (boolean) """ + if valid_return_codes is None: + valid_return_codes = {0, 10} if side == "Buy": - buySellCode = "49" + buy_sell_code = "49" elif side == "Sell": - buySellCode = "50" + buy_sell_code = "50" else: raise Exception("side must be either Buy or Sell") @@ -294,44 +450,46 @@ def trade_v2(self, # Max 2 decimal places allowed for price >= $1 and 4 decimal places for price < $1. if limit_price >= 1: if decimal_places > 2: - limit_price = round(limit_price,2) - limit_price_warning = f"For limit_price >= 1, Only 2 decimal places allowed. Rounded price_limit to: {limit_price}" + limit_price = round(limit_price, 2) + limit_price_warning = \ + f"For limit_price >= 1, Only 2 decimal places allowed. Rounded price_limit to: {limit_price}" else: if decimal_places > 4: - limit_price = round(limit_price,4) - limit_price_warning = f"For limit_price < 1, Only 4 decimal places allowed. Rounded price_limit to: {limit_price}" + limit_price = round(limit_price, 4) + limit_price_warning = \ + f"For limit_price < 1, Only 4 decimal places allowed. Rounded price_limit to: {limit_price}" self.update_token(token_type='update') data = { "UserContext": { - "AccountId":str(account_id), - "AccountColor":0 + "AccountId": str(account_id), + "AccountColor": 0 }, "OrderStrategy": { - "PrimarySecurityType":primary_security_type, + "PrimarySecurityType": primary_security_type, "CostBasisRequest": { - "costBasisMethod":costBasis, - "defaultCostBasisMethod":costBasis + "costBasisMethod": cost_basis, + "defaultCostBasisMethod": cost_basis }, - "OrderType":str(order_type), - "LimitPrice":str(limit_price), - "StopPrice":str(stop_price), - "Duration":str(duration), - "AllNoneIn":False, - "DoNotReduceIn":False, - "OrderStrategyType":1, - "OrderLegs":[ + "OrderType": str(order_type), + "LimitPrice": str(limit_price), + "StopPrice": str(stop_price), + "Duration": str(duration), + "AllNoneIn": False, + "DoNotReduceIn": False, + "OrderStrategyType": 1, + "OrderLegs": [ { - "Quantity":str(qty), - "LeavesQuantity":str(qty), - "Instrument":{"Symbol":ticker}, - "SecurityType":primary_security_type, - "Instruction":buySellCode + "Quantity": str(qty), + "LeavesQuantity": str(qty), + "Instrument": {"Symbol": ticker}, + "SecurityType": primary_security_type, + "Instruction": buy_sell_code } - ]}, + ]}, # OrderProcessingControl seems to map to verification vs actually placing an order. - "OrderProcessingControl":1 + "OrderProcessingControl": 1 } # Adding this header seems to be necessary. @@ -343,10 +501,10 @@ def trade_v2(self, response = json.loads(r.text) - orderId = response['orderStrategy']['orderId'] - firstOrderLeg = response['orderStrategy']['orderLegs'][0] - if "schwabSecurityId" in firstOrderLeg: - data["OrderStrategy"]["OrderLegs"][0]["Instrument"]["ItemIssueId"] = firstOrderLeg["schwabSecurityId"] + order_id = response['orderStrategy']['orderId'] + first_order_leg = response['orderStrategy']['orderLegs'][0] + if "schwabSecurityId" in first_order_leg: + data["OrderStrategy"]["OrderLegs"][0]["Instrument"]["ItemIssueId"] = first_order_leg["schwabSecurityId"] messages = list() if limit_price_warning is not None: @@ -363,7 +521,7 @@ def trade_v2(self, # Make the same POST request, but for real this time. data["UserContext"]["CustomerId"] = 0 - data["OrderStrategy"]["OrderId"] = int(orderId) + data["OrderStrategy"]["OrderId"] = int(order_id) data["OrderProcessingControl"] = 2 if affirm_order: data["OrderStrategy"]["OrderAffrmIn"] = True @@ -387,21 +545,20 @@ def trade_v2(self, return messages, False - def option_trade_v2(self, - strategy, - symbols, - instructions, - quantities, - account_id, - order_type, - dry_run=True, - duration=48, - limit_price=0, - stop_price=0, - valid_return_codes = {0,10}, - affirm_order=False - ): + strategy, + symbols, + instructions, + quantities, + account_id, + order_type, + dry_run=True, + duration=48, + limit_price=0, + stop_price=0, + valid_return_codes=None, + affirm_order=False + ): """ Disclaimer: Use at own risk. @@ -441,7 +598,8 @@ def option_trade_v2(self, account_id (int) - The account ID to place the trade on. If the ID is XXXX-XXXX, we're looking for just XXXXXXXX. order_type (int) - The order type. This is a Schwab-specific number. - 49 - Market. Warning: Options are typically less liquid than stocks! limit orders strongly recommended! + 49 - Market. Warning: Options are typically less liquid than stocks! limit orders strongly + recommended! 201 - Net credit. To be used in conjuncture with limit price. 202 - Net debit. To be used in conjunture with limit price. duration (int) - The duration type for the order. @@ -478,11 +636,14 @@ def option_trade_v2(self, Setting this to True will likely provide the verification needed to execute these orders. You will likely also have to include the appropriate return code in valid_return_codes. - Note: this function calls the new Schwab API, which is flakier and seems to have stricter authentication requirements. + Note: this function calls the new Schwab API, which is flakier and seems to have stricter authentication + requirements. For now, only use this function if the regular trade function doesn't work for your use case. Returns messages (list of strings), is_success (boolean) """ + if valid_return_codes is None: + valid_return_codes = {0, 10} if not (len(quantities) == len(symbols) and len(symbols) == len(instructions)): raise ValueError("variables quantities, symbols and instructions must have the same length") @@ -497,11 +658,11 @@ def option_trade_v2(self, self.update_token(token_type='update') data = { - "UserContext": { + "UserContext": { "AccountId": str(account_id), "AccountColor": 0 - }, - "OrderStrategy": { + }, + "OrderStrategy": { "PrimarySecurityType": 48, "CostBasisRequest": None, "OrderType": str(order_type), @@ -522,7 +683,7 @@ def option_trade_v2(self, "SecurityType": 48, "Instruction": instruction } for qty, symbol, instruction in zip(quantities, symbols, instruction_codes) - ]}, + ]}, # OrderProcessingControl seems to map to verification vs actually placing an order. "OrderProcessingControl": 1 } @@ -536,11 +697,11 @@ def option_trade_v2(self, response = json.loads(r.text) - orderId = response['orderStrategy']['orderId'] + order_id = response['orderStrategy']['orderId'] for i in range(len(symbols)): - OrderLeg = response['orderStrategy']['orderLegs'][i] - if "schwabSecurityId" in OrderLeg: - data["OrderStrategy"]["OrderLegs"][i]["Instrument"]["ItemIssueId"] = OrderLeg["schwabSecurityId"] + order_leg = response['orderStrategy']['orderLegs'][i] + if "schwabSecurityId" in order_leg: + data["OrderStrategy"]["OrderLegs"][i]["Instrument"]["ItemIssueId"] = order_leg["schwabSecurityId"] messages = list() for message in response["orderStrategy"]["orderMessages"]: @@ -555,7 +716,7 @@ def option_trade_v2(self, # Make the same POST request, but for real this time. data["UserContext"]["CustomerId"] = 0 - data["OrderStrategy"]["OrderId"] = int(orderId) + data["OrderStrategy"]["OrderId"] = int(order_id) data["OrderProcessingControl"] = 2 if affirm_order: data["OrderStrategy"]["OrderAffrmIn"] = True @@ -568,8 +729,6 @@ def option_trade_v2(self, response = json.loads(r.text) messages = list() - if limit_price_warning is not None: - messages.append(limit_price_warning) if "orderMessages" in response["orderStrategy"] and response["orderStrategy"]["orderMessages"] is not None: for message in response["orderStrategy"]["orderMessages"]: messages.append(message["message"]) @@ -584,7 +743,7 @@ def cancel_order_v2( # The fields below are experimental and should only be changed if you know what # you're doing. instrument_type=46, - ): + ): """ Cancels an open order (specified by order ID) using the v2 API @@ -603,12 +762,12 @@ def cancel_order_v2( "IsLiveOrder": True, "InstrumentType": instrument_type, "CancelOrderLegs": [{}], - }], + }], "ContingentIdToCancel": 0, "OrderIdToCancel": 0, "OrderProcessingControl": 1, "ConfirmCancelOrderId": 0, - } + } self.headers["schwab-client-account"] = account_id self.headers["schwab-resource-version"] = '2.0' # Web interface uses bearer token retrieved from: @@ -647,9 +806,9 @@ def quote_v2(self, tickers): quote_v2 takes a list of Tickers, and returns Quote information through the Schwab API. """ data = { - "Symbols":tickers, - "IsIra":False, - "AccountRegType":"S3" + "Symbols": tickers, + "IsIra": False, + "AccountRegType": "S3" } # Adding this header seems to be necessary. @@ -701,7 +860,8 @@ def get_account_info_v2(self): position["symbolDetail"]["symbol"], position["symbolDetail"]["description"], float(position["quantity"]), - 0 if "costDetail" not in position else float(position["costDetail"]["costBasisDetail"]["costBasis"]), + 0 if "costDetail" not in position else float( + position["costDetail"]["costBasisDetail"]["costBasis"]), 0 if "priceDetail" not in position else float(position["priceDetail"]["marketValue"]), position["symbolDetail"]["schwabSecurityId"] )._as_dict() @@ -765,21 +925,22 @@ def get_lot_info_v2(self, account_id, security_id): is_success = r.status_code in [200, 207] return is_success, (is_success and json.loads(r.text) or r.text) - def get_options_chains_v2(self, ticker, greeks = False): + def get_options_chains_v2(self, ticker, greeks=False): """ Please do not abuse this API call. It is pulling all the option chains for a ticker. - It's not reverse engineered to the point where you can narrow it down to a range of strike prices and expiration dates. + It's not reverse engineered to the point where you can narrow it down to a range of strike prices and + expiration dates. To look up an individual symbol's quote, prefer using quote_v2(). ticker (str) - ticker of the underlying security greeks (bool) - if greeks is true, you will also get the option greeks (Delta, Theta, Gamma etc... ) """ data = { - "Symbol":ticker, + "Symbol": ticker, "IncludeGreeks": "true" if greeks else "false" } - - full_url= urllib.parse.urljoin(urls.option_chains_v2(), '?' + urllib.parse.urlencode(data)) + + full_url = urllib.parse.urljoin(urls.option_chains_v2(), '?' + urllib.parse.urlencode(data)) # Adding this header seems to be necessary. self.headers['schwab-resource-version'] = '1.0' diff --git a/schwab_api/totp_generator.py b/schwab_api/totp_generator.py index 5129c96..7fc513a 100644 --- a/schwab_api/totp_generator.py +++ b/schwab_api/totp_generator.py @@ -1,6 +1,7 @@ import base64 from vipaccess import provision as vp + def generate_totp(): """ Generates an authentication pair of Symantec VIP ID, and TOTP Secret diff --git a/schwab_api/urls.py b/schwab_api/urls.py index ae39297..15dd96b 100644 --- a/schwab_api/urls.py +++ b/schwab_api/urls.py @@ -1,47 +1,72 @@ - def homepage(): return "https://www.schwab.com/" + def account_summary(): return "https://client.schwab.com/clientapps/accounts/summary/" + def trade_ticket(): return "https://client.schwab.com/app/trade/tom/trade?ShowUN=YES" + # New API def order_verification_v2(): return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/orders" + def account_info_v2(): - return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/customer/accounts" + return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/customer" \ + "/accounts" + def positions_v2(): - return "https://ausgateway.schwab.com/api/is.Holdings/V1/Holdings/Holdings?=&includeCostBasis=true&includeRatings=true&includeUnderlyingOption=true" + return "https://ausgateway.schwab.com/api/is.Holdings/V1/Holdings/Holdings?=&includeCostBasis=true&includeRatings" \ + "=true&includeUnderlyingOption=true" + def ticker_quotes_v2(): - return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/market/quotes/list" + return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/market/quotes" \ + "/list" + def orders_v2(): - return "https://ausgateway.schwab.com/api/is.TradeOrderStatusWeb/ITradeOrderStatusWeb/ITradeOrderStatusWebPort/orders/listView?DateRange=All&OrderStatusType=All&SecurityType=AllSecurities&Type=All&ShowAdvanceOrder=true&SortOrder=Ascending&SortColumn=Status&CostMethod=M&IsSimOrManagedAccount=false&EnableDateFilterByActivity=true" + return "https://ausgateway.schwab.com/api/is.TradeOrderStatusWeb/ITradeOrderStatusWeb/ITradeOrderStatusWebPort" \ + "/orders/listView?DateRange=All&OrderStatusType=All&SecurityType=AllSecurities&Type=All&ShowAdvanceOrder" \ + "=true&SortOrder=Ascending&SortColumn=Status&CostMethod=M&IsSimOrManagedAccount=false" \ + "&EnableDateFilterByActivity=true" + def cancel_order_v2(): - return "https://ausgateway.schwab.com/api/is.TradeOrderStatusWeb/ITradeOrderStatusWeb/ITradeOrderStatusWebPort/orders/cancelorder" + return "https://ausgateway.schwab.com/api/is.TradeOrderStatusWeb/ITradeOrderStatusWeb/ITradeOrderStatusWebPort" \ + "/orders/cancelorder" + def transaction_history_v2(): - return "https://ausgateway.schwab.com/api/is.TransactionHistoryWeb/TransactionHistoryInterface/TransactionHistory/brokerage/transactions/export" + return "https://ausgateway.schwab.com/api/is.TransactionHistoryWeb/TransactionHistoryInterface/TransactionHistory" \ + "/brokerage/transactions/export" + def lot_details_v2(): return "https://ausgateway.schwab.com/api/is.Holdings/V1/Lots" + def option_chains_v2(): return "https://ausgateway.schwab.com/api/is.CSOptionChainsWeb/v1/OptionChainsPort/OptionChains/chains" + # Old API def positions_data(): return "https://client.schwab.com/api/PositionV2/PositionsDataV2" + def order_verification(): return "https://client.schwab.com/api/ts/stamp/verifyOrder" + def order_confirmation(): return "https://client.schwab.com/api/ts/stamp/confirmorder" + + +def trade_page(): + return "https://client.schwab.com/app/trade/tom/#/trade" From e339020d32e6933eadff0765b74ed6265d88fa7c Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Thu, 4 Apr 2024 15:31:55 -0700 Subject: [PATCH 02/18] Reconciled rebalancing code with updated api. --- schwab_api/authentication.py | 8 +-- schwab_api/schwab.py | 117 ++++++++++++++++------------------- 2 files changed, 53 insertions(+), 72 deletions(-) diff --git a/schwab_api/authentication.py b/schwab_api/authentication.py index 22d9126..9e28003 100644 --- a/schwab_api/authentication.py +++ b/schwab_api/authentication.py @@ -103,18 +103,12 @@ async def _async_login(self, username, password, totp_secret=None): await self.page.wait_for_selector("#_txtSymbol") - await self._async_save_session() + await self._async_save_and_close_session() return True async def _async_save_and_close_session(self): - await self._async_save_session() - await self.async_close_session() - - async def _async_save_session(self): cookies = {cookie["name"]: cookie["value"] for cookie in await self.page.context.cookies()} self.session.cookies = cookiejar_from_dict(cookies) - - async def async_close_session(self): await self.page.close() await self.browser.close() await self.playwright.stop() diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index 3a2a4c5..bd9fc96 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -204,38 +204,17 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ return messages, False - def get_current_price(self, ticker): - with self.page.expect_navigation(): - self.page.goto(urls.trade_page()) - - self.page.click("#_txtSymbol") - self.page.fill("#_txtSymbol", ticker) - self.page.press("#_txtSymbol", "Enter") - self.page.press("#_txtSymbol", "Enter") - - self.page.wait_for_selector("//span[@class='mcaio--last-price ']") - money = self.page.text_content("//span[@class='mcaio--last-price ']") - price = float(sub(r'[^\d.]', '', money)) - return price - - def get_available_funds(self, account_id): - with self.page.expect_navigation(): - self.page.goto(urls.trade_page()) - - account_id_string = str(account_id) - button_label = "Account ending in %c %c %c" % (account_id_string[-3], account_id_string[-2], - account_id_string[-1]) - self.page.locator("#basic-example-small").click() - self.page.locator("//li[@class='sdps-account-selector__list-item']").get_by_label(button_label).click() - self.page.wait_for_timeout(1960) - self.page.wait_for_selector("//span[@class='mcaio-balances_total']") - money = self.page.text_content("//span[@class='mcaio-balances_total']") - funds = float(sub(r'[^\d.]', '', money)) - return funds + def get_current_price(self, ticker: str) -> float: + quotation = self.quote_v2([ticker])[0] + return float(sub(r'[^\d.]', '', quotation['quote']['last'])) + + def get_available_funds(self, account_id: int) -> float: + accounts = self.get_account_info_v2() + return accounts[account_id]["available_cash"] def rebalance(self, account_id: int, allocations: {}, set_aside=0.0, reinvest=True, tax_optimized_cost_basis=True, - dry_run=True) -> ([str], True): - account_info = self.get_account_info() + dry_run=True, sell_all_confirm=False) -> ([str], True): + account_info = self.get_account_info_v2() result_messages = [] account = account_info[account_id] portfolio_value = account['account_value'] - set_aside @@ -243,27 +222,34 @@ def rebalance(self, account_id: int, allocations: {}, set_aside=0.0, reinvest=Tr for position in account['positions']: current_shares[position['symbol']] = position['quantity'] - total_allocation = 0.0 - for symbol in allocations: - if allocations[symbol] < 0.0: - raise ValueError("Rebalance allocation for %s is less than zero." % symbol) - total_allocation += allocations[symbol] - desired_shares = {} - prices = {} - lowest_price = 9999999.99 cheapest = None - for symbol in allocations: - prices[symbol] = self.get_current_price(symbol) - if prices[symbol] < lowest_price: - lowest_price = prices[symbol] - cheapest = symbol - allocation = allocations[symbol] / total_allocation - desired_shares[symbol] = portfolio_value * allocation / prices[symbol] - if symbol in current_shares: - desired_shares[symbol] -= current_shares[symbol] - del current_shares[symbol] - desired_shares[symbol] = round(desired_shares[symbol]) + if len(allocations) == 0: + if not sell_all_confirm: + return ['Attempting to rebalance with no allocations. ' + 'If that is the intent, set sell_all_confirm to True.'], True + else: + total_allocation = 0.0 + for symbol in allocations: + if allocations[symbol] < 0.0: + raise ValueError("Rebalance allocation for %s is less than zero." % symbol) + total_allocation += allocations[symbol] + + prices = {} + lowest_price = 999999999.99 + quotations = self.quote_v2(list(allocations.keys())) + for quotation in quotations: + prices[(symbol := quotation['symbol'])] = float(sub(r'[^\d.]', '', quotation['quote']['last'])) + if prices[symbol] < lowest_price: + lowest_price = prices[symbol] + cheapest = symbol + for symbol in allocations: + allocation = allocations[symbol] / total_allocation + desired_shares[symbol] = portfolio_value * allocation / prices[symbol] + if symbol in current_shares: + desired_shares[symbol] -= current_shares[symbol] + del current_shares[symbol] + desired_shares[symbol] = round(desired_shares[symbol]) for symbol in current_shares: messages, success = self.sell(account_id, symbol, current_shares[symbol], @@ -282,23 +268,24 @@ def rebalance(self, account_id: int, allocations: {}, set_aside=0.0, reinvest=Tr return result_messages, success del desired_shares[symbol] - purchase_order = sorted(desired_shares.items(), key=lambda i: prices[i[0]], reverse=True) - for item in purchase_order[:-1]: - messages, success = self.purchase(account_id, item[0], item[1], set_aside, reinvest=reinvest, - tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) - result_messages += messages - if not success: - return result_messages, success - - messages, success = self.purchase(account_id, purchase_order[-1][0], None, set_aside, reinvest=reinvest, - tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) - result_messages += messages + if len(allocations) > 0: + purchase_order = sorted(desired_shares.items(), key=lambda i: prices[i[0]], reverse=True) + for item in purchase_order[:-1]: + messages, success = self.purchase(account_id, item[0], item[1], set_aside, reinvest=reinvest, + tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) + result_messages += messages + if not success: + return result_messages, success + if len(purchase_order) > 0: + messages, success = self.purchase(account_id, purchase_order[-1][0], None, set_aside, reinvest=reinvest, + tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) + result_messages += messages - # If enough cash leftover, purchase the cheapest shares - if cheapest != purchase_order[-1][0]: - messages, success = self.purchase(account_id, cheapest, None, set_aside, reinvest=reinvest, - tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) - result_messages += messages + # If enough cash leftover, purchase the cheapest shares + if len(purchase_order) > 0 and cheapest != purchase_order[-1][0]: + messages, success = self.purchase(account_id, cheapest, None, set_aside, reinvest=reinvest, + tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) + result_messages += messages return result_messages, success From a2cae85d4ccfa29f96a6a88d7be0ab0d60be5b21 Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Thu, 4 Apr 2024 17:15:16 -0700 Subject: [PATCH 03/18] Didn't need the added url after all. --- schwab_api/urls.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/schwab_api/urls.py b/schwab_api/urls.py index 15dd96b..704e244 100644 --- a/schwab_api/urls.py +++ b/schwab_api/urls.py @@ -67,6 +67,3 @@ def order_verification(): def order_confirmation(): return "https://client.schwab.com/api/ts/stamp/confirmorder" - -def trade_page(): - return "https://client.schwab.com/app/trade/tom/#/trade" From b5272092f53ad044597af3f8e61a01f838e7a391 Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Tue, 16 Apr 2024 14:23:33 -0700 Subject: [PATCH 04/18] Added function documentation --- schwab_api/schwab.py | 137 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 126 insertions(+), 11 deletions(-) diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index a4f6b03..427c5bb 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -121,13 +121,14 @@ def get_transaction_history_v2(self, account_id): def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_optimized_cost_basis=True): """ - ticker (Str) - The symbol you want to trade, + ticker (str) - The ticker symbol to trade, side (str) - Either 'Buy' or 'Sell', qty (float) - The amount of shares to buy/sell, account_id (int) - The account ID to place the trade on. If the ID is XXXX-XXXX, we're looking for just XXXXXXXX, - reinvest (boolean) - Reinvest dividends, - tax_optimized_cost_basis (boolean) - Use tax optimized cost-basis strategy, as opposed to FIFO. + dry_run (bool) - Dry run, don't actually perform trades, + reinvest (bool) - Reinvest dividends, + tax_optimized_cost_basis (bool) - Use tax optimized cost-basis strategy, as opposed to FIFO. Returns messages (list of strings), is_success (boolean) """ @@ -205,15 +206,70 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ return messages, False def get_current_price(self, ticker: str) -> float: + """Get current price + :param ticker: str + """ quotation = self.quote_v2([ticker])[0] return float(sub(r'[^\d.]', '', quotation['quote']['last'])) def get_available_funds(self, account_id: int) -> float: + """Get cash funds available for purchases in the account + :param account_id: int + """ accounts = self.get_account_info_v2() return accounts[account_id]["available_cash"] - def rebalance(self, account_id: int, allocations: {}, set_aside=0.0, reinvest=True, tax_optimized_cost_basis=True, - dry_run=True, sell_all_confirm=False) -> ([str], True): + def rebalance(self, account_id: int, allocations: {}, set_aside: float = 0.0, + reinvest: bool = True, tax_optimized_cost_basis: bool = True, dry_run=True, + sell_all_confirm=False) -> ([str], bool): + """Rebalance account portfolio + + Warning - this function performs equity trades that may have tax consequences. + + Perform sales and purchases to achieve a desired proportional investment in a set of equities. For example, + lets say it's desired to have 33% of the account in Apple, 50% in Tesla, and the rest in IBM. The allocations + argument can be set to show the relative desired weighting as {'AAPL': 1.0, 'TSLA': 1.5, 'IBM': 0.5}. If the + account currently had 25% in Apple, 60% in Tesla, and 15% in Google, then all the Google stock would be sold, + some Tesla would be sold, some additional Apple stock would be purchased, and IBM would be purchased. + + Because of the need to purchase in whole share units, the resulting holdings are approximations of the + desired holdings. May specify an amount of cash to set aside in the account, in which case the desired + holding percentages apply to the balance after the set-aside. Once all transactions have occurred, if there is + enough cash remaining to purchase one or more shares of the least expensive desired stock, those will be + purchased as well, to minimize leftover cash. + + The function returns a list of messages related to the sale and purchase transactions, and an indication of + its ultimate success. It may be the case that some, but not all the transactions succeed. In that case, the + function returns after the first unsuccessful transaction. + + Parameters + ---------- + account_id : int + The account ID to place the trades on + allocations : {str:float} + Symbols desired to have in the account, correlated to their relative weights - + for example: {'AAPL': 1.0, 'TSLA': 1.5, 'IBM': 0.5} + set_aside : float, optional + The amount of cash, in dollars, to set aside in the account during rebalancing, by default 0.0 + reinvest : bool, optional + Reinvest dividends that occur into the equities that granted them, by default True + tax_optimized_cost_basis : bool, optional + Use tax-optimized cost-basis strategy, rather than FIFO, by default True + dry_run : bool, optional + Dry run, return potential trades and issues but don't actually perform trades, by default True + sell_all_confirm : bool, optional + Confirmation that an empty allocations argument indicates an intent to sell all holdings, by default + False + + Returns + ------- + result_summary : ([str], bool) + List of messages indicating the trades attempted and resulting messages returned by Schwab, followed by + True if all attempted trades were successful, otherwise the last attempted trade failed + """ + + if allocations is None: + allocations = {} account_info = self.get_account_info_v2() result_messages = [] account = account_info[account_id] @@ -224,10 +280,12 @@ def rebalance(self, account_id: int, allocations: {}, set_aside=0.0, reinvest=Tr desired_shares = {} cheapest = None + success = True + if len(allocations) == 0: if not sell_all_confirm: return ['Attempting to rebalance with no allocations. ' - 'If that is the intent, set sell_all_confirm to True.'], True + 'If that is the intent, set sell_all_confirm to True.'], success else: total_allocation = 0.0 for symbol in allocations: @@ -289,9 +347,37 @@ def rebalance(self, account_id: int, allocations: {}, set_aside=0.0, reinvest=Tr return result_messages, success - def sell(self, account_id, ticker, shares, tax_optimized_cost_basis=True, dry_run=True): + def sell(self, account_id: int, ticker: str, shares: float, tax_optimized_cost_basis: bool = True, + dry_run: bool = True) -> ([str], bool): + """Sell shares + + Warning - this function performs equity trades that may have tax consequences. + + Perform sale of equity shares from a given account portfolio. + + Parameters + ---------- + account_id : int + The account ID in which to place the sale + ticker : str + The ticker symbol to sell + shares : float + Number of shares to sell + tax_optimized_cost_basis : bool, optional + Use tax-optimized cost-basis strategy, rather than FIFO, by default True + dry_run : bool, optional + Dry run, return potential sale and issues but don't actually perform sale, by default True + + Returns + ------- + result_summary : ([str], bool) + List of messages indicating the attempted sale and resulting messages returned by Schwab, followed by + True if the attempted sale was successful + """ + result_messages = ["--> Selling from account %s: %f shares of %s" % (account_id, shares, ticker)] if shares <= 0: + # An attempt to sell zero shares is not a failure - zero shares are successfully (not) sold. return result_messages + ["Attempt to sell invalid number of shares: %f" % shares], shares == 0 messages, success = self.trade( ticker=ticker, @@ -304,8 +390,37 @@ def sell(self, account_id, ticker, shares, tax_optimized_cost_basis=True, dry_ru result_messages += messages return result_messages, success - def purchase(self, account_id, ticker, shares=None, set_aside=0.00, reinvest=True, tax_optimized_cost_basis=True, - dry_run=True): + def purchase(self, account_id: int, ticker: str, shares: float = None, set_aside: float = 0.00, + reinvest: bool = True, tax_optimized_cost_basis: bool = True, dry_run: bool = True) -> ([str], bool): + """Purchase shares + + Perform purchase of equity shares within a given account portfolio. If the shares parameter is not provided, + purchase as many shares as possible with the available funds that haven't been set aside. + + Parameters + ---------- + account_id : int + The account ID in which to place the purchase + ticker : str + The ticker symbol to purchase + shares : float, optional + Number of shares to purchase, default None + set_aside : float, optional + The amount of cash, in dollars, to set aside in the account, by default 0.0 + reinvest : bool, optional + Reinvest dividends that occur into the same equity, by default True + tax_optimized_cost_basis : bool, optional + Use tax-optimized cost-basis strategy, rather than FIFO, by default True + dry_run : bool, optional + Dry run, return potential purchase and issues but don't actually perform purchase, by default True + + Returns + ------- + result_summary : ([str], bool) + List of messages indicating the attempted purchase and resulting messages returned by Schwab, followed + by True if the attempted purchase was successful + """ + result_intro_message = "<-- Purchase request for account %s of %s" % (account_id, ticker) if shares is None: result_intro_message += ", as many shares as possible" @@ -430,7 +545,7 @@ def trade_v2(self, else: raise Exception("side must be either Buy or Sell") - # Handling formating of limit_price to avoid error. + # Handling formatting of limit_price to avoid error. # Checking how many decimal places are in limit_price. decimal_places = len(str(float(limit_price)).split('.')[1]) limit_price_warning = None @@ -788,7 +903,7 @@ def cancel_order_v2( return [r2.text], False return response, False - def quote_v2(self, tickers): + def quote_v2(self, tickers) -> {}: """ quote_v2 takes a list of Tickers, and returns Quote information through the Schwab API. """ From d4b0c499cc4c61eb457377810b104778b8374b97 Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Thu, 18 Apr 2024 17:21:28 -0700 Subject: [PATCH 05/18] Small bug fix. --- schwab_api/schwab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index 427c5bb..ff66b6e 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -427,7 +427,7 @@ def purchase(self, account_id: int, ticker: str, shares: float = None, set_aside else: result_intro_message += ", %d shares" % shares if set_aside > 0.00: - result_intro_message += ", with $.2f set aside" % set_aside + result_intro_message += ", with $%.2f set aside" % set_aside result_messages = [result_intro_message] funds = self.get_available_funds(account_id) - set_aside result_messages.append("Funds available in account %s: $%.2f" % (account_id, funds)) From cde6af5df8c4532995f0df64257dab8aed8af9e1 Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Fri, 14 Jun 2024 15:26:24 -0700 Subject: [PATCH 06/18] Retry purchase with affirmation. Added stop-loss orders and cancellations. --- schwab_api/schwab.py | 199 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 162 insertions(+), 37 deletions(-) diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index ff66b6e..f05f1e9 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -1,4 +1,6 @@ +import datetime import json +import re import urllib.parse import requests from re import sub @@ -8,6 +10,13 @@ from .authentication import SessionManager +def _get_current_shares(account_summary): + # Filter out un-sellable symbols + current_shares = {position['symbol']: position['quantity'] for position in account_summary['positions'] + if re.match(r"^[-A-Z]+$", position['symbol'])} + return current_shares + + class Schwab(SessionManager): def __init__(self, **kwargs): """ @@ -171,6 +180,8 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ if dry_run: return messages, True + short_description = urllib.parse.quote_plus(response['IssueShortDescription']) \ + if response['IssueShortDescription'] is not None else '' payload = { "AccountId": str(account_id), "ActionType": side, @@ -185,7 +196,7 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ "OrderType": "Market", "Principal": response['QuoteAmount'], "Quantity": str(qty), - "ShortDescription": urllib.parse.quote_plus(response['IssueShortDescription']), + "ShortDescription": short_description, "Symbol": response["IssueSymbol"], "Timing": "Day Only" } @@ -205,12 +216,21 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ return messages, False + def get_current_prices(self, symbols: list[str]) -> dict[str, float]: + """Get current prices + :param symbols: list[str] + """ + quotations = self.quote_v2(symbols) + prices = {} + for quotation in quotations: + prices[quotation['symbol']] = float(sub(r'[^\d.]', '', quotation['quote']['last'])) + return prices + def get_current_price(self, ticker: str) -> float: """Get current price :param ticker: str """ - quotation = self.quote_v2([ticker])[0] - return float(sub(r'[^\d.]', '', quotation['quote']['last'])) + return self.get_current_prices([ticker]).get(ticker) def get_available_funds(self, account_id: int) -> float: """Get cash funds available for purchases in the account @@ -219,9 +239,9 @@ def get_available_funds(self, account_id: int) -> float: accounts = self.get_account_info_v2() return accounts[account_id]["available_cash"] - def rebalance(self, account_id: int, allocations: {}, set_aside: float = 0.0, - reinvest: bool = True, tax_optimized_cost_basis: bool = True, dry_run=True, - sell_all_confirm=False) -> ([str], bool): + def rebalance(self, account_id: int, allocations: dict[str, float], set_aside: float = 0.0, + reinvest: bool = True, tax_optimized_cost_basis: bool = True, dry_run: bool = True, + sell_all_confirm: bool = False) -> tuple[list[str], bool]: """Rebalance account portfolio Warning - this function performs equity trades that may have tax consequences. @@ -233,7 +253,7 @@ def rebalance(self, account_id: int, allocations: {}, set_aside: float = 0.0, some Tesla would be sold, some additional Apple stock would be purchased, and IBM would be purchased. Because of the need to purchase in whole share units, the resulting holdings are approximations of the - desired holdings. May specify an amount of cash to set aside in the account, in which case the desired + desired holdings. Caller may specify an amount of cash to set aside in the account, in which case the desired holding percentages apply to the balance after the set-aside. Once all transactions have occurred, if there is enough cash remaining to purchase one or more shares of the least expensive desired stock, those will be purchased as well, to minimize leftover cash. @@ -263,7 +283,7 @@ def rebalance(self, account_id: int, allocations: {}, set_aside: float = 0.0, Returns ------- - result_summary : ([str], bool) + result_summary : tuple(list[str], bool) List of messages indicating the trades attempted and resulting messages returned by Schwab, followed by True if all attempted trades were successful, otherwise the last attempted trade failed """ @@ -274,10 +294,7 @@ def rebalance(self, account_id: int, allocations: {}, set_aside: float = 0.0, result_messages = [] account = account_info[account_id] portfolio_value = account['account_value'] - set_aside - current_shares = {} - for position in account['positions']: - current_shares[position['symbol']] = position['quantity'] - + current_shares = _get_current_shares(account) desired_shares = {} cheapest = None success = True @@ -293,11 +310,9 @@ def rebalance(self, account_id: int, allocations: {}, set_aside: float = 0.0, raise ValueError("Rebalance allocation for %s is less than zero." % symbol) total_allocation += allocations[symbol] - prices = {} + prices = self.get_current_prices(list(allocations.keys())) lowest_price = 999999999.99 - quotations = self.quote_v2(list(allocations.keys())) - for quotation in quotations: - prices[(symbol := quotation['symbol'])] = float(sub(r'[^\d.]', '', quotation['quote']['last'])) + for symbol in prices: if prices[symbol] < lowest_price: lowest_price = prices[symbol] cheapest = symbol @@ -348,7 +363,7 @@ def rebalance(self, account_id: int, allocations: {}, set_aside: float = 0.0, return result_messages, success def sell(self, account_id: int, ticker: str, shares: float, tax_optimized_cost_basis: bool = True, - dry_run: bool = True) -> ([str], bool): + dry_run: bool = True) -> tuple[list[str], bool]: """Sell shares Warning - this function performs equity trades that may have tax consequences. @@ -370,7 +385,7 @@ def sell(self, account_id: int, ticker: str, shares: float, tax_optimized_cost_b Returns ------- - result_summary : ([str], bool) + result_summary : tuple[list[str], bool] List of messages indicating the attempted sale and resulting messages returned by Schwab, followed by True if the attempted sale was successful """ @@ -391,7 +406,8 @@ def sell(self, account_id: int, ticker: str, shares: float, tax_optimized_cost_b return result_messages, success def purchase(self, account_id: int, ticker: str, shares: float = None, set_aside: float = 0.00, - reinvest: bool = True, tax_optimized_cost_basis: bool = True, dry_run: bool = True) -> ([str], bool): + reinvest: bool = True, tax_optimized_cost_basis: bool = True, + dry_run: bool = True) -> tuple[list[str], bool]: """Purchase shares Perform purchase of equity shares within a given account portfolio. If the shares parameter is not provided, @@ -416,7 +432,7 @@ def purchase(self, account_id: int, ticker: str, shares: float = None, set_aside Returns ------- - result_summary : ([str], bool) + result_summary : tuple[list[str], bool] List of messages indicating the attempted purchase and resulting messages returned by Schwab, followed by True if the attempted purchase was successful """ @@ -448,6 +464,107 @@ def purchase(self, account_id: int, ticker: str, shares: float = None, set_aside dry_run=dry_run ) result_messages += messages + if not success: + result_messages.append(f"Retrying purchase in account {account_id} using affirmation: {shares} shares of " + f"{ticker}") + messages, success = self.trade_v2( + ticker, + "Buy", + shares, + account_id, + affirm_order=True, + cost_basis='BTAX' if tax_optimized_cost_basis else 'FIFO', + reinvest=reinvest, + dry_run=dry_run, + valid_return_codes={0, 10, 20, 25} + ) + result_messages += messages + + return result_messages, success + + def cancel_all_stop_loss_orders(self, account_id: int, dry_run: bool = True) -> tuple[list[str], bool]: + """Cancel all stop-loss orders + + Cancels all pending stop-loss orders within a given account portfolio. + + Parameters + ---------- + account_id : int + The account ID in which to cancel stop-loss orders + dry_run : bool, optional + Dry run, return potential cancellation issues but don't actually perform them, by default True + + Returns + ------- + result_summary : tuple[list[str], bool] + List of messages indicating the attempted stop-loss cancellations and resulting messages returned by + Schwab, followed by True if the attempted set of cancellations was successful. Stops after first + failure. + """ + + response = self.orders_v2(account_id) + order_ids = [] + for order_group in response: + order_ids += [order['OrderId'] for order in order_group['OrderList'] + if order['IsLiveOrder'] and order['OrderStatus'] == 'Open' and order['OrderAction'] == + 'Sell' and order['Price'].startswith('Stop ')] + account_id_string = str(account_id) + succeeded = True + cancellation_results = [] + for order_id in order_ids: + cancellation_results.append(f"Cancelling Stop Loss order {order_id} in account {account_id}") + response, cancel_success = \ + self.cancel_order_v2(account_id_string, order_id) if not dry_run else "Dry run - not executed", True + cancellation_results.append(response) + succeeded = cancel_success + if not succeeded: + break + return cancellation_results, succeeded + + def fractional_account_stop_loss(self, account_id: int, fraction: float, + dry_run: bool = True) -> tuple[list[str], bool]: + """Creates stop-loss orders at fraction of the current price + + Creates 90-day stop-loss orders for each security in the account at the given fraction of the current price. + + Parameters + ---------- + account_id : int + The account ID in which to create stop-loss orders + fraction : float + The fraction of the current price at which to set the stop prices + dry_run : bool, optional + Dry run, return potential stop-loss order issues but don't actually perform them, by default True + + Returns + ------- + result_summary : tuple[list[str], bool] + List of messages indicating the attempted stop-loss orders and resulting messages returned by + Schwab, followed by True if the attempted set of orders was successful. Stops after first + failure. + """ + + if fraction == 0.0: + return [], True + if fraction >= 1.0 or fraction < 0.0: + raise ValueError(f"Stop-loss fraction ({fraction}) must be greater than or equal to 0, and less than 1.0") + account_info = self.get_account_info_v2() + account = account_info[account_id] + expiration_date = (datetime.datetime.now() + datetime.timedelta(days=90)).strftime("%m/%d/%Y") + current_shares = _get_current_shares(account) + prices = self.get_current_prices(list(current_shares.keys())) + result_messages = [] + success = True + for ticker in current_shares: + stop_price = round(prices[ticker] * fraction, 2) + result_messages.append(f"Placing stop-loss order in account {account_id}: {current_shares[ticker]} shares " + f"of {ticker} at ${stop_price}") + messages, success = self.trade_v2(ticker, 'Sell', current_shares[ticker], account_id, dry_run=dry_run, + order_type=51, duration=49, expiration_date=expiration_date, + stop_price=stop_price, valid_return_codes={0, 10, 20}) + result_messages += messages + if not success: + break return result_messages, success def trade_v2(self, @@ -459,12 +576,14 @@ def trade_v2(self, # The Fields below are experimental fields that should only be changed if you know what you're doing. order_type=49, duration=48, - limit_price=0, - stop_price=0, + limit_price=0.0, + stop_price=0.0, primary_security_type=46, - valid_return_codes=None, + valid_return_codes=None, # Set to {0, 10} below affirm_order=False, - cost_basis='FIFO' + cost_basis='FIFO', + reinvest=False, + expiration_date=None ): """ ticker (Str) - The symbol you want to trade, @@ -520,7 +639,7 @@ def trade_v2(self, Setting this to True will likely provide the verification needed to execute these orders. You will likely also have to include the appropriate return code in valid_return_codes. - costBasis (str) - Set the cost basis for a sell order. Important tax implications. See: + cost_basis (str) - Set the cost basis for a sell order. Important tax implications. See: https://help.streetsmart.schwab.com/edge/1.22/Content/Cost%20Basis%20Method.htm Only tested FIFO and BTAX. 'FIFO': First In First Out @@ -529,6 +648,8 @@ def trade_v2(self, 'LIFO': Last In First Out 'BTAX': Tax Lot Optimizer ('VSP': Specific Lots -> just for reference. Not implemented: Requires to select lots manually.) + reinvest - if purchasing, reinvest dividends that occur into the same equity, by default False + expiration_date - Required when duration is 49 - GTC Good till canceled. Of the form 'mm/dd/YYYY' Note: this function calls the new Schwab API, which is flakier and seems to have stricter authentication requirements. For now, only use this function if the regular trade function doesn't work for your use case. @@ -563,7 +684,7 @@ def trade_v2(self, self.update_token(token_type='update') - data = { + payload = { "UserContext": { "AccountId": str(account_id), "AccountColor": 0 @@ -593,11 +714,15 @@ def trade_v2(self, # OrderProcessingControl seems to map to verification vs actually placing an order. "OrderProcessingControl": 1 } + if duration == 49 and expiration_date is not None: + payload["OrderStrategy"]["ExpirationDate"] = expiration_date + if side == "Buy": + payload["OrderStrategy"]["ReinvestDividend"] = reinvest # Adding this header seems to be necessary. self.headers['schwab-resource-version'] = '1.0' - r = requests.post(urls.order_verification_v2(), json=data, headers=self.headers) + r = requests.post(urls.order_verification_v2(), json=payload, headers=self.headers) if r.status_code != 200: return [r.text], False @@ -606,13 +731,13 @@ def trade_v2(self, order_id = response['orderStrategy']['orderId'] first_order_leg = response['orderStrategy']['orderLegs'][0] if "schwabSecurityId" in first_order_leg: - data["OrderStrategy"]["OrderLegs"][0]["Instrument"]["ItemIssueId"] = first_order_leg["schwabSecurityId"] + payload["OrderStrategy"]["OrderLegs"][0]["Instrument"]["ItemIssueId"] = first_order_leg["schwabSecurityId"] messages = list() if limit_price_warning is not None: messages.append(limit_price_warning) for message in response["orderStrategy"]["orderMessages"]: - messages.append(message["message"]) + messages.append("Severity %s: %s" % (message["severity"], message["message"])) # TODO: This needs to be fleshed out and clarified. if response["orderStrategy"]["orderReturnCode"] not in valid_return_codes: @@ -622,25 +747,24 @@ def trade_v2(self, return messages, True # Make the same POST request, but for real this time. - data["UserContext"]["CustomerId"] = 0 - data["OrderStrategy"]["OrderId"] = int(order_id) - data["OrderProcessingControl"] = 2 + payload["UserContext"]["CustomerId"] = 0 + payload["OrderStrategy"]["OrderId"] = int(order_id) + payload["OrderProcessingControl"] = 2 if affirm_order: - data["OrderStrategy"]["OrderAffrmIn"] = True + payload["OrderStrategy"]["OrderAffrmIn"] = True self.update_token(token_type='update') - r = requests.post(urls.order_verification_v2(), json=data, headers=self.headers) + r = requests.post(urls.order_verification_v2(), json=payload, headers=self.headers) if r.status_code != 200: return [r.text], False response = json.loads(r.text) - messages = list() if limit_price_warning is not None: messages.append(limit_price_warning) if "orderMessages" in response["orderStrategy"] and response["orderStrategy"]["orderMessages"] is not None: for message in response["orderStrategy"]["orderMessages"]: - messages.append(message["message"]) + messages.append("Severity %s: %s" % (message["severity"], message["message"])) if response["orderStrategy"]["orderReturnCode"] in valid_return_codes: return messages, True @@ -722,6 +846,7 @@ def option_trade_v2(self, - Quote at the time of order verification: $xx.xx Verification response messages with severity 20 include at least: - Insufficient settled funds (different from insufficient buying power) + - Stop prices do not guarantee execution (trade) prices Verification response messages with severity 25 include at least: - This order is executable because the buy (or sell) limit is higher (lower) than the ask (bid) price. @@ -934,7 +1059,7 @@ def orders_v2(self, account_id=None): self.update_token(token_type='api') self.headers['schwab-resource-version'] = '2.0' if account_id: - self.headers["schwab-client-account"] = account_id + self.headers["schwab-client-account"] = str(account_id) r = requests.get(urls.orders_v2(), headers=self.headers) if r.status_code != 200: return [r.text], False From dfa4ab29584d4b69c117c0228ab9a7997c1977ed Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Fri, 14 Jun 2024 16:19:27 -0700 Subject: [PATCH 07/18] Debugged stop-losses and rebalancing on empty accounts. --- schwab_api/schwab.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index f05f1e9..ffbbba2 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -220,10 +220,11 @@ def get_current_prices(self, symbols: list[str]) -> dict[str, float]: """Get current prices :param symbols: list[str] """ - quotations = self.quote_v2(symbols) + quotations, success = self.quote_v2(symbols) prices = {} - for quotation in quotations: - prices[quotation['symbol']] = float(sub(r'[^\d.]', '', quotation['quote']['last'])) + if success: + for quotation in quotations: + prices[quotation['symbol']] = float(sub(r'[^\d.]', '', quotation['quote']['last'])) return prices def get_current_price(self, ticker: str) -> float: @@ -1032,6 +1033,9 @@ def quote_v2(self, tickers) -> {}: """ quote_v2 takes a list of Tickers, and returns Quote information through the Schwab API. """ + if len(tickers) == 0: + return [], True + data = { "Symbols": tickers, "IsIra": False, @@ -1047,7 +1051,7 @@ def quote_v2(self, tickers) -> {}: return [r.text], False response = json.loads(r.text) - return response["quotes"] + return response["quotes"], True def orders_v2(self, account_id=None): """ From f9109643c0bc49df92350331569f6ad8cdb9dc4f Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Mon, 17 Jun 2024 13:48:55 -0700 Subject: [PATCH 08/18] Minor print format bug fix. --- schwab_api/schwab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index a4f6b03..702d5ae 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -312,7 +312,7 @@ def purchase(self, account_id, ticker, shares=None, set_aside=0.00, reinvest=Tru else: result_intro_message += ", %d shares" % shares if set_aside > 0.00: - result_intro_message += ", with $.2f set aside" % set_aside + result_intro_message += ", with $%.2f set aside" % set_aside result_messages = [result_intro_message] funds = self.get_available_funds(account_id) - set_aside result_messages.append("Funds available in account %s: $%.2f" % (account_id, funds)) From 9367a7b7ecbab3efe719f9a8db8454084fdb2ff7 Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Fri, 9 Aug 2024 10:04:11 -0700 Subject: [PATCH 09/18] Limit least shares to purchase to zero. --- schwab_api/schwab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index ffbbba2..90f8b25 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -449,7 +449,7 @@ def purchase(self, account_id: int, ticker: str, shares: float = None, set_aside funds = self.get_available_funds(account_id) - set_aside result_messages.append("Funds available in account %s: $%.2f" % (account_id, funds)) current_price = self.get_current_price(ticker) - potential_shares = funds // current_price + potential_shares = max(funds // current_price, 0) if shares is None or potential_shares < shares: shares = potential_shares if shares <= 0: From 2d6fb4a1fb2aa7fc6c7bed60a88c690ce28c1099 Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Tue, 22 Oct 2024 10:39:03 -0700 Subject: [PATCH 10/18] A couple tweaks to get it working again. --- schwab_api/authentication.py | 2 +- schwab_api/schwab.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/schwab_api/authentication.py b/schwab_api/authentication.py index 722498e..8d4f118 100644 --- a/schwab_api/authentication.py +++ b/schwab_api/authentication.py @@ -18,7 +18,7 @@ class SessionManager: - def __init__(self, browser_type="firefox", headless=False) -> None: + def __init__(self, debug = False, browser_type="firefox", headless=False) -> None: """ This class is using asynchronous playwright mode. """ diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index 20fea2c..4235b5b 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -227,7 +227,7 @@ def get_current_prices(self, symbols: list[str]) -> dict[str, float]: prices = {} if success: for quotation in quotations: - prices[quotation['symbol']] = float(sub(r'[^\d.]', '', quotation['quote']['last'])) + prices[quotation['symbol']] = float(re.sub(r'[^\d.]', '', quotation['quote']['last'])) return prices def get_current_price(self, ticker: str) -> float: From b8e25a7b73b3eaaae19c6e2be802de5f6c78118e Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Wed, 23 Oct 2024 15:01:08 -0700 Subject: [PATCH 11/18] Narrowed changes down to minimum needed from origin. --- schwab_api/account_information.py | 5 +- schwab_api/authentication.py | 18 +- schwab_api/schwab.py | 600 ++++++------------------------ schwab_api/totp_generator.py | 1 - schwab_api/urls.py | 36 +- 5 files changed, 132 insertions(+), 528 deletions(-) diff --git a/schwab_api/account_information.py b/schwab_api/account_information.py index 6294597..1d2d4b8 100644 --- a/schwab_api/account_information.py +++ b/schwab_api/account_information.py @@ -1,6 +1,5 @@ class Account(dict): def __init__(self, account_id, positions, market_value, available_cash, account_value, cost_basis): - super().__init__() self.account_id = account_id self.positions = positions self.market_value = market_value @@ -24,10 +23,8 @@ def __repr__(self) -> str: def __str__(self) -> str: return str(self._as_dict()) - class Position(dict): def __init__(self, symbol, description, quantity, cost, market_value, security_id): - super().__init__() self.symbol = symbol self.description = description self.quantity = quantity @@ -49,4 +46,4 @@ def __repr__(self) -> str: return str(self._as_dict()) def __str__(self) -> str: - return str(self._as_dict()) + return str(self._as_dict()) \ No newline at end of file diff --git a/schwab_api/authentication.py b/schwab_api/authentication.py index 8d4f118..905ab45 100644 --- a/schwab_api/authentication.py +++ b/schwab_api/authentication.py @@ -14,17 +14,17 @@ # Constants USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{version}) Gecko/20100101 Firefox/" -VIEWPORT = {'width': 1920, 'height': 1080} - +VIEWPORT = { 'width': 1920, 'height': 1080 } class SessionManager: - def __init__(self, debug = False, browser_type="firefox", headless=False) -> None: - """ + def __init__(self, debug = False) -> None: + """ This class is using asynchronous playwright mode. + + :type debug: boolean + :param debug: Enable debug logging """ - self.browserType = browser_type - self.headless = headless - self.headers = None + self.headers = {} self.session = requests.Session() self.playwright = None self.browser = None @@ -164,9 +164,7 @@ async def _async_login(self): try: await self.page.frame(name=login_frame).press("[placeholder=\"Password\"]", "Enter") - # Making it more robust than specifying an exact url which may change. - await self.page.wait_for_url(re.compile(r"app/trade"), - wait_until="domcontentloaded") + await self.page.wait_for_url(re.compile(r"app/trade"), wait_until="domcontentloaded") # Making it more robust than specifying an exact url which may change. except TimeoutError: raise Exception("Login was not successful; please check username and password") diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index 4235b5b..7579d08 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -1,6 +1,4 @@ -import datetime import json -import re import urllib.parse import requests import sys @@ -9,14 +7,6 @@ from .account_information import Position, Account from .authentication import SessionManager - -def _get_current_shares(account_summary): - # Filter out un-sellable symbols - current_shares = {position['symbol']: position['quantity'] for position in account_summary['positions'] - if re.match(r"^[-A-Z]+$", position['symbol'])} - return current_shares - - class Schwab(SessionManager): def __init__(self, session_cache=None, **kwargs): """ @@ -28,7 +18,7 @@ def __init__(self, session_cache=None, **kwargs): self.headless = kwargs.get("headless", True) self.browserType = kwargs.get("browserType", "firefox") self.session_cache = session_cache - super(Schwab, self).__init__(headless=self.headless, browser_type=self.browserType) + super(Schwab, self).__init__() def get_account_info(self): """ @@ -146,35 +136,35 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ """ if side == "Buy": - buy_sell_code = 1 + buySellCode = 1 elif side == "Sell": - buy_sell_code = 2 + buySellCode = 2 else: raise Exception("side must be either Buy or Sell") cost_basis_method = 'BTAX' if tax_optimized_cost_basis else 'FIFO' - payload = { - "IsMinQty": False, - "CustomerId": str(account_id), - "BuySellCode": buy_sell_code, - "Quantity": str(qty), - "SecurityId": ticker, - "TimeInForce": "1", # Day Only - "OrderType": 1, # Market Order + data = { + "IsMinQty":False, + "CustomerId":str(account_id), + "BuySellCode":buySellCode, + "Quantity":str(qty), + "SecurityId":ticker, + "TimeInForce":"1", # Day Only + "OrderType":1, # Market Order "CblMethod": cost_basis_method, "CblDefault": cost_basis_method, "CostBasis": cost_basis_method, } if side == "Buy": - payload["IsReinvestDividends"] = reinvest + data["IsReinvestDividends"] = reinvest - reply = self.session.post(urls.order_verification(), payload) + r = self.session.post(urls.order_verification(), data) - if reply.status_code != 200: - return [reply.text], False + if r.status_code != 200: + return [r.text], False - response = json.loads(reply.text) + response = json.loads(r.text) messages = list() for message in response["Messages"]: @@ -185,7 +175,7 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ short_description = urllib.parse.quote_plus(response['IssueShortDescription']) \ if response['IssueShortDescription'] is not None else '' - payload = { + data = { "AccountId": str(account_id), "ActionType": side, "ActionTypeText": side, @@ -204,13 +194,13 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ "Timing": "Day Only" } - reply = self.session.post(urls.order_confirmation(), payload) + r = self.session.post(urls.order_confirmation(), data) - if reply.status_code != 200: - messages.append(reply.text) + if r.status_code != 200: + messages.append(r.text) return messages, False - response = json.loads(reply.text) + response = json.loads(r.text) for message in response["Messages"]: messages.append(message["Message"]) @@ -219,376 +209,24 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ return messages, False - def get_current_prices(self, symbols: list[str]) -> dict[str, float]: - """Get current prices - :param symbols: list[str] - """ - quotations, success = self.quote_v2(symbols) - prices = {} - if success: - for quotation in quotations: - prices[quotation['symbol']] = float(re.sub(r'[^\d.]', '', quotation['quote']['last'])) - return prices - - def get_current_price(self, ticker: str) -> float: - """Get current price - :param ticker: str - """ - return self.get_current_prices([ticker]).get(ticker) - - def get_available_funds(self, account_id: int) -> float: - """Get cash funds available for purchases in the account - :param account_id: int - """ - accounts = self.get_account_info_v2() - return accounts[account_id]["available_cash"] - - def rebalance(self, account_id: int, allocations: dict[str, float], set_aside: float = 0.0, - reinvest: bool = True, tax_optimized_cost_basis: bool = True, dry_run: bool = True, - sell_all_confirm: bool = False) -> tuple[list[str], bool]: - """Rebalance account portfolio - - Warning - this function performs equity trades that may have tax consequences. - - Perform sales and purchases to achieve a desired proportional investment in a set of equities. For example, - lets say it's desired to have 33% of the account in Apple, 50% in Tesla, and the rest in IBM. The allocations - argument can be set to show the relative desired weighting as {'AAPL': 1.0, 'TSLA': 1.5, 'IBM': 0.5}. If the - account currently had 25% in Apple, 60% in Tesla, and 15% in Google, then all the Google stock would be sold, - some Tesla would be sold, some additional Apple stock would be purchased, and IBM would be purchased. - - Because of the need to purchase in whole share units, the resulting holdings are approximations of the - desired holdings. Caller may specify an amount of cash to set aside in the account, in which case the desired - holding percentages apply to the balance after the set-aside. Once all transactions have occurred, if there is - enough cash remaining to purchase one or more shares of the least expensive desired stock, those will be - purchased as well, to minimize leftover cash. - - The function returns a list of messages related to the sale and purchase transactions, and an indication of - its ultimate success. It may be the case that some, but not all the transactions succeed. In that case, the - function returns after the first unsuccessful transaction. - - Parameters - ---------- - account_id : int - The account ID to place the trades on - allocations : {str:float} - Symbols desired to have in the account, correlated to their relative weights - - for example: {'AAPL': 1.0, 'TSLA': 1.5, 'IBM': 0.5} - set_aside : float, optional - The amount of cash, in dollars, to set aside in the account during rebalancing, by default 0.0 - reinvest : bool, optional - Reinvest dividends that occur into the equities that granted them, by default True - tax_optimized_cost_basis : bool, optional - Use tax-optimized cost-basis strategy, rather than FIFO, by default True - dry_run : bool, optional - Dry run, return potential trades and issues but don't actually perform trades, by default True - sell_all_confirm : bool, optional - Confirmation that an empty allocations argument indicates an intent to sell all holdings, by default - False - - Returns - ------- - result_summary : tuple(list[str], bool) - List of messages indicating the trades attempted and resulting messages returned by Schwab, followed by - True if all attempted trades were successful, otherwise the last attempted trade failed - """ - - if allocations is None: - allocations = {} - account_info = self.get_account_info_v2() - result_messages = [] - account = account_info[account_id] - portfolio_value = account['account_value'] - set_aside - current_shares = _get_current_shares(account) - desired_shares = {} - cheapest = None - success = True - - if len(allocations) == 0: - if not sell_all_confirm: - return ['Attempting to rebalance with no allocations. ' - 'If that is the intent, set sell_all_confirm to True.'], success - else: - total_allocation = 0.0 - for symbol in allocations: - if allocations[symbol] < 0.0: - raise ValueError("Rebalance allocation for %s is less than zero." % symbol) - total_allocation += allocations[symbol] - - prices = self.get_current_prices(list(allocations.keys())) - lowest_price = 999999999.99 - for symbol in prices: - if prices[symbol] < lowest_price: - lowest_price = prices[symbol] - cheapest = symbol - for symbol in allocations: - allocation = allocations[symbol] / total_allocation - desired_shares[symbol] = portfolio_value * allocation / prices[symbol] - if symbol in current_shares: - desired_shares[symbol] -= current_shares[symbol] - del current_shares[symbol] - desired_shares[symbol] = round(desired_shares[symbol]) - - for symbol in current_shares: - messages, success = self.sell(account_id, symbol, current_shares[symbol], - tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) - result_messages += messages - if not success: - return result_messages, success - - for symbol in set(desired_shares.keys()): - if desired_shares[symbol] <= 0: - if desired_shares[symbol] < 0: - messages, success = self.sell(account_id, symbol, -desired_shares[symbol], - tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) - result_messages += messages - if not success: - return result_messages, success - del desired_shares[symbol] - - if len(allocations) > 0: - purchase_order = sorted(desired_shares.items(), key=lambda i: prices[i[0]], reverse=True) - for item in purchase_order[:-1]: - messages, success = self.purchase(account_id, item[0], item[1], set_aside, reinvest=reinvest, - tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) - result_messages += messages - if not success: - return result_messages, success - if len(purchase_order) > 0: - messages, success = self.purchase(account_id, purchase_order[-1][0], None, set_aside, reinvest=reinvest, - tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) - result_messages += messages - - # If enough cash leftover, purchase the cheapest shares - if len(purchase_order) > 0 and cheapest != purchase_order[-1][0]: - messages, success = self.purchase(account_id, cheapest, None, set_aside, reinvest=reinvest, - tax_optimized_cost_basis=tax_optimized_cost_basis, dry_run=dry_run) - result_messages += messages - - return result_messages, success - - def sell(self, account_id: int, ticker: str, shares: float, tax_optimized_cost_basis: bool = True, - dry_run: bool = True) -> tuple[list[str], bool]: - """Sell shares - - Warning - this function performs equity trades that may have tax consequences. - - Perform sale of equity shares from a given account portfolio. - - Parameters - ---------- - account_id : int - The account ID in which to place the sale - ticker : str - The ticker symbol to sell - shares : float - Number of shares to sell - tax_optimized_cost_basis : bool, optional - Use tax-optimized cost-basis strategy, rather than FIFO, by default True - dry_run : bool, optional - Dry run, return potential sale and issues but don't actually perform sale, by default True - - Returns - ------- - result_summary : tuple[list[str], bool] - List of messages indicating the attempted sale and resulting messages returned by Schwab, followed by - True if the attempted sale was successful - """ - - result_messages = ["--> Selling from account %s: %f shares of %s" % (account_id, shares, ticker)] - if shares <= 0: - # An attempt to sell zero shares is not a failure - zero shares are successfully (not) sold. - return result_messages + ["Attempt to sell invalid number of shares: %f" % shares], shares == 0 - messages, success = self.trade( - ticker=ticker, - side="Sell", - qty=shares, - account_id=account_id, - tax_optimized_cost_basis=tax_optimized_cost_basis, - dry_run=dry_run - ) - result_messages += messages - return result_messages, success - - def purchase(self, account_id: int, ticker: str, shares: float = None, set_aside: float = 0.00, - reinvest: bool = True, tax_optimized_cost_basis: bool = True, - dry_run: bool = True) -> tuple[list[str], bool]: - """Purchase shares - - Perform purchase of equity shares within a given account portfolio. If the shares parameter is not provided, - purchase as many shares as possible with the available funds that haven't been set aside. - - Parameters - ---------- - account_id : int - The account ID in which to place the purchase - ticker : str - The ticker symbol to purchase - shares : float, optional - Number of shares to purchase, default None - set_aside : float, optional - The amount of cash, in dollars, to set aside in the account, by default 0.0 - reinvest : bool, optional - Reinvest dividends that occur into the same equity, by default True - tax_optimized_cost_basis : bool, optional - Use tax-optimized cost-basis strategy, rather than FIFO, by default True - dry_run : bool, optional - Dry run, return potential purchase and issues but don't actually perform purchase, by default True - - Returns - ------- - result_summary : tuple[list[str], bool] - List of messages indicating the attempted purchase and resulting messages returned by Schwab, followed - by True if the attempted purchase was successful - """ - - result_intro_message = "<-- Purchase request for account %s of %s" % (account_id, ticker) - if shares is None: - result_intro_message += ", as many shares as possible" - else: - result_intro_message += ", %d shares" % shares - if set_aside > 0.00: - result_intro_message += ", with $%.2f set aside" % set_aside - result_messages = [result_intro_message] - funds = self.get_available_funds(account_id) - set_aside - result_messages.append("Funds available in account %s: $%.2f" % (account_id, funds)) - current_price = self.get_current_price(ticker) - potential_shares = max(funds // current_price, 0) - if shares is None or potential_shares < shares: - shares = potential_shares - if shares <= 0: - return result_messages + ["Attempt to purchase invalid number of shares: %d" % shares], shares == 0 - result_messages.append("Purchasing in account %s: %d shares of %s" % (account_id, shares, ticker)) - messages, success = self.trade( - ticker=ticker, - side="Buy", - qty=shares, - account_id=account_id, - tax_optimized_cost_basis=tax_optimized_cost_basis, - reinvest=reinvest, - dry_run=dry_run - ) - result_messages += messages - if not success: - result_messages.append(f"Retrying purchase in account {account_id} using affirmation: {shares} shares of " - f"{ticker}") - messages, success = self.trade_v2( - ticker, - "Buy", - shares, - account_id, - affirm_order=True, - cost_basis='BTAX' if tax_optimized_cost_basis else 'FIFO', - reinvest=reinvest, - dry_run=dry_run, - valid_return_codes={0, 10, 20, 25} - ) - result_messages += messages - - return result_messages, success - - def cancel_all_stop_loss_orders(self, account_id: int, dry_run: bool = True) -> tuple[list[str], bool]: - """Cancel all stop-loss orders - - Cancels all pending stop-loss orders within a given account portfolio. - - Parameters - ---------- - account_id : int - The account ID in which to cancel stop-loss orders - dry_run : bool, optional - Dry run, return potential cancellation issues but don't actually perform them, by default True - - Returns - ------- - result_summary : tuple[list[str], bool] - List of messages indicating the attempted stop-loss cancellations and resulting messages returned by - Schwab, followed by True if the attempted set of cancellations was successful. Stops after first - failure. - """ - - response = self.orders_v2(account_id) - order_ids = [] - for order_group in response: - order_ids += [order['OrderId'] for order in order_group['OrderList'] - if order['IsLiveOrder'] and order['OrderStatus'] == 'Open' and order['OrderAction'] == - 'Sell' and order['Price'].startswith('Stop ')] - account_id_string = str(account_id) - succeeded = True - cancellation_results = [] - for order_id in order_ids: - cancellation_results.append(f"Cancelling Stop Loss order {order_id} in account {account_id}") - response, cancel_success = \ - self.cancel_order_v2(account_id_string, order_id) if not dry_run else "Dry run - not executed", True - cancellation_results.append(response) - succeeded = cancel_success - if not succeeded: - break - return cancellation_results, succeeded - - def fractional_account_stop_loss(self, account_id: int, fraction: float, - dry_run: bool = True) -> tuple[list[str], bool]: - """Creates stop-loss orders at fraction of the current price - - Creates 90-day stop-loss orders for each security in the account at the given fraction of the current price. - - Parameters - ---------- - account_id : int - The account ID in which to create stop-loss orders - fraction : float - The fraction of the current price at which to set the stop prices - dry_run : bool, optional - Dry run, return potential stop-loss order issues but don't actually perform them, by default True - - Returns - ------- - result_summary : tuple[list[str], bool] - List of messages indicating the attempted stop-loss orders and resulting messages returned by - Schwab, followed by True if the attempted set of orders was successful. Stops after first - failure. - """ - - if fraction == 0.0: - return [], True - if fraction >= 1.0 or fraction < 0.0: - raise ValueError(f"Stop-loss fraction ({fraction}) must be greater than or equal to 0, and less than 1.0") - account_info = self.get_account_info_v2() - account = account_info[account_id] - expiration_date = (datetime.datetime.now() + datetime.timedelta(days=90)).strftime("%m/%d/%Y") - current_shares = _get_current_shares(account) - prices = self.get_current_prices(list(current_shares.keys())) - result_messages = [] - success = True - for ticker in current_shares: - stop_price = round(prices[ticker] * fraction, 2) - result_messages.append(f"Placing stop-loss order in account {account_id}: {current_shares[ticker]} shares " - f"of {ticker} at ${stop_price}") - messages, success = self.trade_v2(ticker, 'Sell', current_shares[ticker], account_id, dry_run=dry_run, - order_type=51, duration=49, expiration_date=expiration_date, - stop_price=stop_price, valid_return_codes={0, 10, 20}) - result_messages += messages - if not success: - break - return result_messages, success - def trade_v2(self, - ticker, - side, - qty, - account_id, - dry_run=True, - # The Fields below are experimental fields that should only be changed if you know what you're doing. - order_type=49, - duration=48, - limit_price=0.0, - stop_price=0.0, - primary_security_type=46, - valid_return_codes=None, # Set to {0, 10} below - affirm_order=False, - cost_basis='FIFO', - reinvest=False, - expiration_date=None - ): + ticker, + side, + qty, + account_id, + dry_run=True, + # The Fields below are experimental fields that should only be changed if you know what you're doing. + order_type=49, + duration=48, + limit_price=0.0, + stop_price=0.0, + primary_security_type=46, + valid_return_codes=None, # Set to {0, 10} below + affirm_order=False, + costBasis='FIFO', + reinvest=False, + expiration_date=None + ): """ ticker (Str) - The symbol you want to trade, side (str) - Either 'Buy' or 'Sell', @@ -643,7 +281,7 @@ def trade_v2(self, Setting this to True will likely provide the verification needed to execute these orders. You will likely also have to include the appropriate return code in valid_return_codes. - cost_basis (str) - Set the cost basis for a sell order. Important tax implications. See: + costBasis (str) - Set the cost basis for a sell order. Important tax implications. See: https://help.streetsmart.schwab.com/edge/1.22/Content/Cost%20Basis%20Method.htm Only tested FIFO and BTAX. 'FIFO': First In First Out @@ -654,8 +292,7 @@ def trade_v2(self, ('VSP': Specific Lots -> just for reference. Not implemented: Requires to select lots manually.) reinvest - if purchasing, reinvest dividends that occur into the same equity, by default False expiration_date - Required when duration is 49 - GTC Good till canceled. Of the form 'mm/dd/YYYY' - Note: this function calls the new Schwab API, which is flakier and seems to have stricter authentication - requirements. + Note: this function calls the new Schwab API, which is flakier and seems to have stricter authentication requirements. For now, only use this function if the regular trade function doesn't work for your use case. Returns messages (list of strings), is_success (boolean) @@ -664,78 +301,76 @@ def trade_v2(self, if valid_return_codes is None: valid_return_codes = {0, 10} if side == "Buy": - buy_sell_code = "49" + buySellCode = "49" elif side == "Sell": - buy_sell_code = "50" + buySellCode = "50" else: raise Exception("side must be either Buy or Sell") - # Handling formatting of limit_price to avoid error. + # Handling formating of limit_price to avoid error. # Checking how many decimal places are in limit_price. decimal_places = len(str(float(limit_price)).split('.')[1]) limit_price_warning = None # Max 2 decimal places allowed for price >= $1 and 4 decimal places for price < $1. if limit_price >= 1: if decimal_places > 2: - limit_price = round(limit_price, 2) - limit_price_warning = \ - f"For limit_price >= 1, Only 2 decimal places allowed. Rounded price_limit to: {limit_price}" + limit_price = round(limit_price,2) + limit_price_warning = f"For limit_price >= 1, Only 2 decimal places allowed. Rounded price_limit to: {limit_price}" else: if decimal_places > 4: - limit_price = round(limit_price, 4) - limit_price_warning = \ - f"For limit_price < 1, Only 4 decimal places allowed. Rounded price_limit to: {limit_price}" + limit_price = round(limit_price,4) + limit_price_warning = f"For limit_price < 1, Only 4 decimal places allowed. Rounded price_limit to: {limit_price}" self.update_token(token_type='update') - payload = { + data = { "UserContext": { - "AccountId": str(account_id), - "AccountColor": 0 + "AccountId":str(account_id), + "AccountColor":0 }, "OrderStrategy": { - "PrimarySecurityType": primary_security_type, + "PrimarySecurityType":primary_security_type, "CostBasisRequest": { - "costBasisMethod": cost_basis, - "defaultCostBasisMethod": cost_basis + "costBasisMethod":costBasis, + "defaultCostBasisMethod":costBasis }, - "OrderType": str(order_type), - "LimitPrice": str(limit_price), - "StopPrice": str(stop_price), - "Duration": str(duration), - "AllNoneIn": False, - "DoNotReduceIn": False, - "OrderStrategyType": 1, - "OrderLegs": [ + "OrderType":str(order_type), + "LimitPrice":str(limit_price), + "StopPrice":str(stop_price), + "Duration":str(duration), + "AllNoneIn":False, + "DoNotReduceIn":False, + "OrderStrategyType":1, + "OrderLegs":[ { - "Quantity": str(qty), - "LeavesQuantity": str(qty), - "Instrument": {"Symbol": ticker}, - "SecurityType": primary_security_type, - "Instruction": buy_sell_code + "Quantity":str(qty), + "LeavesQuantity":str(qty), + "Instrument":{"Symbol":ticker}, + "SecurityType":primary_security_type, + "Instruction":buySellCode } - ]}, + ]}, # OrderProcessingControl seems to map to verification vs actually placing an order. - "OrderProcessingControl": 1 + "OrderProcessingControl":1 } if duration == 49 and expiration_date is not None: - payload["OrderStrategy"]["ExpirationDate"] = expiration_date + data["OrderStrategy"]["ExpirationDate"] = expiration_date if side == "Buy": - payload["OrderStrategy"]["ReinvestDividend"] = reinvest + data["OrderStrategy"]["ReinvestDividend"] = reinvest # Adding this header seems to be necessary. self.headers['schwab-resource-version'] = '1.0' - r = requests.post(urls.order_verification_v2(), json=payload, headers=self.headers) + r = requests.post(urls.order_verification_v2(), json=data, headers=self.headers) if r.status_code != 200: return [r.text], False response = json.loads(r.text) - order_id = response['orderStrategy']['orderId'] - first_order_leg = response['orderStrategy']['orderLegs'][0] - if "schwabSecurityId" in first_order_leg: - payload["OrderStrategy"]["OrderLegs"][0]["Instrument"]["ItemIssueId"] = first_order_leg["schwabSecurityId"] + orderId = response['orderStrategy']['orderId'] + firstOrderLeg = response['orderStrategy']['orderLegs'][0] + if "schwabSecurityId" in firstOrderLeg: + data["OrderStrategy"]["OrderLegs"][0]["Instrument"]["ItemIssueId"] = firstOrderLeg["schwabSecurityId"] messages = list() if limit_price_warning is not None: @@ -751,13 +386,13 @@ def trade_v2(self, return messages, True # Make the same POST request, but for real this time. - payload["UserContext"]["CustomerId"] = 0 - payload["OrderStrategy"]["OrderId"] = int(order_id) - payload["OrderProcessingControl"] = 2 + data["UserContext"]["CustomerId"] = 0 + data["OrderStrategy"]["OrderId"] = int(orderId) + data["OrderProcessingControl"] = 2 if affirm_order: - payload["OrderStrategy"]["OrderAffrmIn"] = True + data["OrderStrategy"]["OrderAffrmIn"] = True self.update_token(token_type='update') - r = requests.post(urls.order_verification_v2(), json=payload, headers=self.headers) + r = requests.post(urls.order_verification_v2(), json=data, headers=self.headers) if r.status_code != 200: return [r.text], False @@ -775,20 +410,21 @@ def trade_v2(self, return messages, False + def option_trade_v2(self, - strategy, - symbols, - instructions, - quantities, - account_id, - order_type, - dry_run=True, - duration=48, - limit_price=0, - stop_price=0, - valid_return_codes=None, - affirm_order=False - ): + strategy, + symbols, + instructions, + quantities, + account_id, + order_type, + dry_run=True, + duration=48, + limit_price=0, + stop_price=0, + valid_return_codes=None, + affirm_order=False + ): """ Disclaimer: Use at own risk. @@ -828,8 +464,7 @@ def option_trade_v2(self, account_id (int) - The account ID to place the trade on. If the ID is XXXX-XXXX, we're looking for just XXXXXXXX. order_type (int) - The order type. This is a Schwab-specific number. - 49 - Market. Warning: Options are typically less liquid than stocks! limit orders strongly - recommended! + 49 - Market. Warning: Options are typically less liquid than stocks! limit orders strongly recommended! 201 - Net credit. To be used in conjuncture with limit price. 202 - Net debit. To be used in conjunture with limit price. duration (int) - The duration type for the order. @@ -867,8 +502,7 @@ def option_trade_v2(self, Setting this to True will likely provide the verification needed to execute these orders. You will likely also have to include the appropriate return code in valid_return_codes. - Note: this function calls the new Schwab API, which is flakier and seems to have stricter authentication - requirements. + Note: this function calls the new Schwab API, which is flakier and seems to have stricter authentication requirements. For now, only use this function if the regular trade function doesn't work for your use case. Returns messages (list of strings), is_success (boolean) @@ -889,11 +523,11 @@ def option_trade_v2(self, self.update_token(token_type='update') data = { - "UserContext": { + "UserContext": { "AccountId": str(account_id), "AccountColor": 0 - }, - "OrderStrategy": { + }, + "OrderStrategy": { "PrimarySecurityType": 48, "CostBasisRequest": None, "OrderType": str(order_type), @@ -914,7 +548,7 @@ def option_trade_v2(self, "SecurityType": 48, "Instruction": instruction } for qty, symbol, instruction in zip(quantities, symbols, instruction_codes) - ]}, + ]}, # OrderProcessingControl seems to map to verification vs actually placing an order. "OrderProcessingControl": 1 } @@ -928,11 +562,11 @@ def option_trade_v2(self, response = json.loads(r.text) - order_id = response['orderStrategy']['orderId'] + orderId = response['orderStrategy']['orderId'] for i in range(len(symbols)): - order_leg = response['orderStrategy']['orderLegs'][i] - if "schwabSecurityId" in order_leg: - data["OrderStrategy"]["OrderLegs"][i]["Instrument"]["ItemIssueId"] = order_leg["schwabSecurityId"] + OrderLeg = response['orderStrategy']['orderLegs'][i] + if "schwabSecurityId" in OrderLeg: + data["OrderStrategy"]["OrderLegs"][i]["Instrument"]["ItemIssueId"] = OrderLeg["schwabSecurityId"] messages = list() for message in response["orderStrategy"]["orderMessages"]: @@ -947,7 +581,7 @@ def option_trade_v2(self, # Make the same POST request, but for real this time. data["UserContext"]["CustomerId"] = 0 - data["OrderStrategy"]["OrderId"] = int(order_id) + data["OrderStrategy"]["OrderId"] = int(orderId) data["OrderProcessingControl"] = 2 if affirm_order: data["OrderStrategy"]["OrderAffrmIn"] = True @@ -994,12 +628,12 @@ def cancel_order_v2( "IsLiveOrder": True, "InstrumentType": instrument_type, "CancelOrderLegs": [{}], - }], + }], "ContingentIdToCancel": 0, "OrderIdToCancel": 0, "OrderProcessingControl": 1, "ConfirmCancelOrderId": 0, - } + } self.headers["schwab-client-account"] = account_id self.headers["schwab-resource-version"] = '2.0' # Web interface uses bearer token retrieved from: @@ -1033,7 +667,7 @@ def cancel_order_v2( return [r2.text], False return response, False - def quote_v2(self, tickers) -> {}: + def quote_v2(self, tickers): """ quote_v2 takes a list of Tickers, and returns Quote information through the Schwab API. """ @@ -1041,9 +675,9 @@ def quote_v2(self, tickers) -> {}: return [], True data = { - "Symbols": tickers, - "IsIra": False, - "AccountRegType": "S3" + "Symbols":tickers, + "IsIra":False, + "AccountRegType":"S3" } # Adding this header seems to be necessary. @@ -1095,8 +729,7 @@ def get_account_info_v2(self): position["symbolDetail"]["symbol"], position["symbolDetail"]["description"], float(position["quantity"]), - 0 if "costDetail" not in position else float( - position["costDetail"]["costBasisDetail"]["costBasis"]), + 0 if "costDetail" not in position else float(position["costDetail"]["costBasisDetail"]["costBasis"]), 0 if "priceDetail" not in position else float(position["priceDetail"]["marketValue"]), position["symbolDetail"]["schwabSecurityId"] )._as_dict() @@ -1160,22 +793,21 @@ def get_lot_info_v2(self, account_id, security_id): is_success = r.status_code in [200, 207] return is_success, (is_success and json.loads(r.text) or r.text) - def get_options_chains_v2(self, ticker, greeks=False): + def get_options_chains_v2(self, ticker, greeks = False): """ Please do not abuse this API call. It is pulling all the option chains for a ticker. - It's not reverse engineered to the point where you can narrow it down to a range of strike prices and - expiration dates. + It's not reverse engineered to the point where you can narrow it down to a range of strike prices and expiration dates. To look up an individual symbol's quote, prefer using quote_v2(). ticker (str) - ticker of the underlying security greeks (bool) - if greeks is true, you will also get the option greeks (Delta, Theta, Gamma etc... ) """ data = { - "Symbol": ticker, + "Symbol":ticker, "IncludeGreeks": "true" if greeks else "false" } - full_url = urllib.parse.urljoin(urls.option_chains_v2(), '?' + urllib.parse.urlencode(data)) + full_url= urllib.parse.urljoin(urls.option_chains_v2(), '?' + urllib.parse.urlencode(data)) # Adding this header seems to be necessary. self.headers['schwab-resource-version'] = '1.0' diff --git a/schwab_api/totp_generator.py b/schwab_api/totp_generator.py index 7fc513a..5129c96 100644 --- a/schwab_api/totp_generator.py +++ b/schwab_api/totp_generator.py @@ -1,7 +1,6 @@ import base64 from vipaccess import provision as vp - def generate_totp(): """ Generates an authentication pair of Symantec VIP ID, and TOTP Secret diff --git a/schwab_api/urls.py b/schwab_api/urls.py index 704e244..ae39297 100644 --- a/schwab_api/urls.py +++ b/schwab_api/urls.py @@ -1,69 +1,47 @@ + def homepage(): return "https://www.schwab.com/" - def account_summary(): return "https://client.schwab.com/clientapps/accounts/summary/" - def trade_ticket(): return "https://client.schwab.com/app/trade/tom/trade?ShowUN=YES" - # New API def order_verification_v2(): return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/orders" - def account_info_v2(): - return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/customer" \ - "/accounts" - + return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/customer/accounts" def positions_v2(): - return "https://ausgateway.schwab.com/api/is.Holdings/V1/Holdings/Holdings?=&includeCostBasis=true&includeRatings" \ - "=true&includeUnderlyingOption=true" - + return "https://ausgateway.schwab.com/api/is.Holdings/V1/Holdings/Holdings?=&includeCostBasis=true&includeRatings=true&includeUnderlyingOption=true" def ticker_quotes_v2(): - return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/market/quotes" \ - "/list" - + return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/market/quotes/list" def orders_v2(): - return "https://ausgateway.schwab.com/api/is.TradeOrderStatusWeb/ITradeOrderStatusWeb/ITradeOrderStatusWebPort" \ - "/orders/listView?DateRange=All&OrderStatusType=All&SecurityType=AllSecurities&Type=All&ShowAdvanceOrder" \ - "=true&SortOrder=Ascending&SortColumn=Status&CostMethod=M&IsSimOrManagedAccount=false" \ - "&EnableDateFilterByActivity=true" - + return "https://ausgateway.schwab.com/api/is.TradeOrderStatusWeb/ITradeOrderStatusWeb/ITradeOrderStatusWebPort/orders/listView?DateRange=All&OrderStatusType=All&SecurityType=AllSecurities&Type=All&ShowAdvanceOrder=true&SortOrder=Ascending&SortColumn=Status&CostMethod=M&IsSimOrManagedAccount=false&EnableDateFilterByActivity=true" def cancel_order_v2(): - return "https://ausgateway.schwab.com/api/is.TradeOrderStatusWeb/ITradeOrderStatusWeb/ITradeOrderStatusWebPort" \ - "/orders/cancelorder" - + return "https://ausgateway.schwab.com/api/is.TradeOrderStatusWeb/ITradeOrderStatusWeb/ITradeOrderStatusWebPort/orders/cancelorder" def transaction_history_v2(): - return "https://ausgateway.schwab.com/api/is.TransactionHistoryWeb/TransactionHistoryInterface/TransactionHistory" \ - "/brokerage/transactions/export" - + return "https://ausgateway.schwab.com/api/is.TransactionHistoryWeb/TransactionHistoryInterface/TransactionHistory/brokerage/transactions/export" def lot_details_v2(): return "https://ausgateway.schwab.com/api/is.Holdings/V1/Lots" - def option_chains_v2(): return "https://ausgateway.schwab.com/api/is.CSOptionChainsWeb/v1/OptionChainsPort/OptionChains/chains" - # Old API def positions_data(): return "https://client.schwab.com/api/PositionV2/PositionsDataV2" - def order_verification(): return "https://client.schwab.com/api/ts/stamp/verifyOrder" - def order_confirmation(): return "https://client.schwab.com/api/ts/stamp/confirmorder" - From 754e89811ac8831e90fc709b0c117bd30ea9198b Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Wed, 23 Oct 2024 15:07:55 -0700 Subject: [PATCH 12/18] Matched tabbing in account_information.py --- schwab_api/account_information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schwab_api/account_information.py b/schwab_api/account_information.py index 1d2d4b8..7256095 100644 --- a/schwab_api/account_information.py +++ b/schwab_api/account_information.py @@ -19,7 +19,7 @@ def _as_dict(self): def __repr__(self) -> str: return str(self._as_dict()) - + def __str__(self) -> str: return str(self._as_dict()) From 33a5eb8f06a4cf5cccde6240f7e1b0612b15733d Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Tue, 3 Jun 2025 16:26:51 -0700 Subject: [PATCH 13/18] Changes to get it to work again. --- schwab_api/schwab.py | 176 ++++++++++++++++++++++--------------------- schwab_api/urls.py | 2 +- 2 files changed, 91 insertions(+), 87 deletions(-) diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index 7579d08..902d1ec 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -7,6 +7,7 @@ from .account_information import Position, Account from .authentication import SessionManager + class Schwab(SessionManager): def __init__(self, session_cache=None, **kwargs): """ @@ -145,13 +146,13 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ cost_basis_method = 'BTAX' if tax_optimized_cost_basis else 'FIFO' data = { - "IsMinQty":False, - "CustomerId":str(account_id), - "BuySellCode":buySellCode, - "Quantity":str(qty), - "SecurityId":ticker, - "TimeInForce":"1", # Day Only - "OrderType":1, # Market Order + "IsMinQty": False, + "CustomerId": str(account_id), + "BuySellCode": buySellCode, + "Quantity": str(qty), + "SecurityId": ticker, + "TimeInForce": "1", # Day Only + "OrderType": 1, # Market Order "CblMethod": cost_basis_method, "CblDefault": cost_basis_method, "CostBasis": cost_basis_method, @@ -210,23 +211,23 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ return messages, False def trade_v2(self, - ticker, - side, - qty, - account_id, - dry_run=True, - # The Fields below are experimental fields that should only be changed if you know what you're doing. - order_type=49, - duration=48, - limit_price=0.0, - stop_price=0.0, - primary_security_type=46, - valid_return_codes=None, # Set to {0, 10} below - affirm_order=False, - costBasis='FIFO', - reinvest=False, - expiration_date=None - ): + ticker, + side, + qty, + account_id, + dry_run=True, + # The Fields below are experimental fields that should only be changed if you know what you're doing. + order_type=49, + duration=48, + limit_price=0.0, + stop_price=0.0, + primary_security_type=46, + valid_return_codes=None, # Set to {0, 10} below + affirm_order=False, + costBasis='FIFO', + reinvest=False, + expiration_date=None + ): """ ticker (Str) - The symbol you want to trade, side (str) - Either 'Buy' or 'Sell', @@ -314,44 +315,44 @@ def trade_v2(self, # Max 2 decimal places allowed for price >= $1 and 4 decimal places for price < $1. if limit_price >= 1: if decimal_places > 2: - limit_price = round(limit_price,2) + limit_price = round(limit_price, 2) limit_price_warning = f"For limit_price >= 1, Only 2 decimal places allowed. Rounded price_limit to: {limit_price}" else: if decimal_places > 4: - limit_price = round(limit_price,4) + limit_price = round(limit_price, 4) limit_price_warning = f"For limit_price < 1, Only 4 decimal places allowed. Rounded price_limit to: {limit_price}" self.update_token(token_type='update') data = { "UserContext": { - "AccountId":str(account_id), - "AccountColor":0 + "AccountId": str(account_id), + "AccountColor": 0 }, "OrderStrategy": { - "PrimarySecurityType":primary_security_type, + "PrimarySecurityType": primary_security_type, "CostBasisRequest": { - "costBasisMethod":costBasis, - "defaultCostBasisMethod":costBasis + "costBasisMethod": costBasis, + "defaultCostBasisMethod": costBasis }, - "OrderType":str(order_type), - "LimitPrice":str(limit_price), - "StopPrice":str(stop_price), - "Duration":str(duration), - "AllNoneIn":False, - "DoNotReduceIn":False, - "OrderStrategyType":1, - "OrderLegs":[ + "OrderType": str(order_type), + "LimitPrice": str(limit_price), + "StopPrice": str(stop_price), + "Duration": str(duration), + "AllNoneIn": False, + "DoNotReduceIn": False, + "OrderStrategyType": 1, + "OrderLegs": [ { - "Quantity":str(qty), - "LeavesQuantity":str(qty), - "Instrument":{"Symbol":ticker}, - "SecurityType":primary_security_type, - "Instruction":buySellCode + "Quantity": str(qty), + "LeavesQuantity": str(qty), + "Instrument": {"Symbol": ticker}, + "SecurityType": primary_security_type, + "Instruction": buySellCode } - ]}, + ]}, # OrderProcessingControl seems to map to verification vs actually placing an order. - "OrderProcessingControl":1 + "OrderProcessingControl": 1 } if duration == 49 and expiration_date is not None: data["OrderStrategy"]["ExpirationDate"] = expiration_date @@ -410,21 +411,20 @@ def trade_v2(self, return messages, False - def option_trade_v2(self, - strategy, - symbols, - instructions, - quantities, - account_id, - order_type, - dry_run=True, - duration=48, - limit_price=0, - stop_price=0, - valid_return_codes=None, - affirm_order=False - ): + strategy, + symbols, + instructions, + quantities, + account_id, + order_type, + dry_run=True, + duration=48, + limit_price=0, + stop_price=0, + valid_return_codes=None, + affirm_order=False + ): """ Disclaimer: Use at own risk. @@ -523,11 +523,11 @@ def option_trade_v2(self, self.update_token(token_type='update') data = { - "UserContext": { + "UserContext": { "AccountId": str(account_id), "AccountColor": 0 - }, - "OrderStrategy": { + }, + "OrderStrategy": { "PrimarySecurityType": 48, "CostBasisRequest": None, "OrderType": str(order_type), @@ -548,7 +548,7 @@ def option_trade_v2(self, "SecurityType": 48, "Instruction": instruction } for qty, symbol, instruction in zip(quantities, symbols, instruction_codes) - ]}, + ]}, # OrderProcessingControl seems to map to verification vs actually placing an order. "OrderProcessingControl": 1 } @@ -608,8 +608,8 @@ def cancel_order_v2( # The fields below are experimental and should only be changed if you know what # you're doing. instrument_type=46, - order_management_system=2, # You may need to change this based on the value returned from calling orders_v2 - ): + order_management_system=2, # You may need to change this based on the value returned from calling orders_v2 + ): """ Cancels an open order (specified by order ID) using the v2 API @@ -628,12 +628,12 @@ def cancel_order_v2( "IsLiveOrder": True, "InstrumentType": instrument_type, "CancelOrderLegs": [{}], - }], + }], "ContingentIdToCancel": 0, "OrderIdToCancel": 0, "OrderProcessingControl": 1, "ConfirmCancelOrderId": 0, - } + } self.headers["schwab-client-account"] = account_id self.headers["schwab-resource-version"] = '2.0' # Web interface uses bearer token retrieved from: @@ -675,9 +675,9 @@ def quote_v2(self, tickers): return [], True data = { - "Symbols":tickers, - "IsIra":False, - "AccountRegType":"S3" + "Symbols": tickers, + "IsIra": False, + "AccountRegType": "S3" } # Adding this header seems to be necessary. @@ -713,6 +713,8 @@ def get_account_info_v2(self): account_info = dict() self.update_token(token_type='api') r = requests.get(urls.positions_v2(), headers=self.headers) + if r.status_code >= 400: + raise urllib.error.HTTPError(r.url, r.status_code, r.reason, r.request.headers, None) response = json.loads(r.text) for account in response['accounts']: positions = list() @@ -720,20 +722,22 @@ def get_account_info_v2(self): for security_group in account["groupedPositions"]: if security_group["groupName"] == "Cash": continue - for position in security_group["positions"]: - if "symbol" not in position["symbolDetail"]: + for position in security_group["holdingsRows"]: + if "symbol" not in position: valid_parse = False break - positions.append( - Position( - position["symbolDetail"]["symbol"], - position["symbolDetail"]["description"], - float(position["quantity"]), - 0 if "costDetail" not in position else float(position["costDetail"]["costBasisDetail"]["costBasis"]), - 0 if "priceDetail" not in position else float(position["priceDetail"]["marketValue"]), - position["symbolDetail"]["schwabSecurityId"] - )._as_dict() - ) + if position["symbol"]["symbol"]: + positions.append( + Position( + position["symbol"]["symbol"], + position["description"], + float(position["qty"]["qty"]), + 0 if "costBasis" not in position or "cstBasis" not in position["costBasis"] else + float(position["costBasis"]["cstBasis"]), + 0 if "marketValue" not in position else float(position["marketValue"]["val"]), + position["symbol"]["ssId"] + )._as_dict() + ) if not valid_parse: continue account_info[int(account["accountId"])] = Account( @@ -793,7 +797,7 @@ def get_lot_info_v2(self, account_id, security_id): is_success = r.status_code in [200, 207] return is_success, (is_success and json.loads(r.text) or r.text) - def get_options_chains_v2(self, ticker, greeks = False): + def get_options_chains_v2(self, ticker, greeks=False): """ Please do not abuse this API call. It is pulling all the option chains for a ticker. It's not reverse engineered to the point where you can narrow it down to a range of strike prices and expiration dates. @@ -803,11 +807,11 @@ def get_options_chains_v2(self, ticker, greeks = False): greeks (bool) - if greeks is true, you will also get the option greeks (Delta, Theta, Gamma etc... ) """ data = { - "Symbol":ticker, + "Symbol": ticker, "IncludeGreeks": "true" if greeks else "false" } - full_url= urllib.parse.urljoin(urls.option_chains_v2(), '?' + urllib.parse.urlencode(data)) + full_url = urllib.parse.urljoin(urls.option_chains_v2(), '?' + urllib.parse.urlencode(data)) # Adding this header seems to be necessary. self.headers['schwab-resource-version'] = '1.0' diff --git a/schwab_api/urls.py b/schwab_api/urls.py index ae39297..a801b26 100644 --- a/schwab_api/urls.py +++ b/schwab_api/urls.py @@ -16,7 +16,7 @@ def account_info_v2(): return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/customer/accounts" def positions_v2(): - return "https://ausgateway.schwab.com/api/is.Holdings/V1/Holdings/Holdings?=&includeCostBasis=true&includeRatings=true&includeUnderlyingOption=true" + return "https://ausgateway.schwab.com/api/is.Holdings/V1/Holdings/HoldingV2" def ticker_quotes_v2(): return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/market/quotes/list" From 9d491061b471510f41532f0e3501fcf90dc037ae Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Wed, 3 Sep 2025 11:05:02 -0700 Subject: [PATCH 14/18] get_account_info_V2 is broken --- schwab_api/schwab.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index 902d1ec..c127c78 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -709,12 +709,12 @@ def orders_v2(self, account_id=None): response = json.loads(r.text) return response["Orders"] - def get_account_info_v2(self): + def get_account_info_v2_broken(self): account_info = dict() self.update_token(token_type='api') r = requests.get(urls.positions_v2(), headers=self.headers) if r.status_code >= 400: - raise urllib.error.HTTPError(r.url, r.status_code, r.reason, r.request.headers, None) + raise urllib.error.HTTPError(r.url, r.status_code, f"{r.reason}: {r.text}", r.request.headers, None) response = json.loads(r.text) for account in response['accounts']: positions = list() From 88e81dadafcf72f6b4ca0963ebb19952adcc4da5 Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Wed, 3 Sep 2025 11:12:22 -0700 Subject: [PATCH 15/18] get_account_info_V2 is broken, adding comment on how --- schwab_api/schwab.py | 1 + 1 file changed, 1 insertion(+) diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index c127c78..ca94aed 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -709,6 +709,7 @@ def orders_v2(self, account_id=None): response = json.loads(r.text) return response["Orders"] + # Now raises urllib.error.HTTPError: HTTP Error 400: Bad Request: "Account number is required." def get_account_info_v2_broken(self): account_info = dict() self.update_token(token_type='api') From 5c0cb38134a6d6d99dd6f30344c0b1791a9de08f Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Wed, 8 Oct 2025 10:37:05 -0700 Subject: [PATCH 16/18] Another defunct function call. Returning http error as part of error messages, as 404 error gets an html webpage back. --- schwab_api/schwab.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index ca94aed..2da4a2d 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -119,9 +119,10 @@ def get_transaction_history_v2(self, account_id): } r = requests.post(urls.transaction_history_v2(), json=data, headers=self.headers) if r.status_code != 200: - return [r.text], False + return [f"Status {r.status_code}: {r.text}"], False return json.loads(r.text) + # Defunct - returns 404 status. Use trade_v2 instead. def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_optimized_cost_basis=True): """ ticker (str) - The ticker symbol to trade, @@ -163,7 +164,7 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ r = self.session.post(urls.order_verification(), data) if r.status_code != 200: - return [r.text], False + return [f"Status {r.status_code}: {r.text}"], False response = json.loads(r.text) @@ -198,7 +199,7 @@ def trade(self, ticker, side, qty, account_id, dry_run=True, reinvest=True, tax_ r = self.session.post(urls.order_confirmation(), data) if r.status_code != 200: - messages.append(r.text) + messages.append(f"Status {r.status_code} {r.text}") return messages, False response = json.loads(r.text) @@ -364,7 +365,7 @@ def trade_v2(self, r = requests.post(urls.order_verification_v2(), json=data, headers=self.headers) if r.status_code != 200: - return [r.text], False + return [f"Status {r.status_code}: {r.text}"], False response = json.loads(r.text) @@ -396,7 +397,7 @@ def trade_v2(self, r = requests.post(urls.order_verification_v2(), json=data, headers=self.headers) if r.status_code != 200: - return [r.text], False + return [f"Status {r.status_code}: {r.text}"], False response = json.loads(r.text) @@ -558,7 +559,7 @@ def option_trade_v2(self, r = requests.post(urls.order_verification_v2(), json=data, headers=self.headers) if r.status_code != 200: - return [r.text], False + return [f"Status {r.status_code}: {r.text}"], False response = json.loads(r.text) @@ -589,7 +590,7 @@ def option_trade_v2(self, r = requests.post(urls.order_verification_v2(), json=data, headers=self.headers) if r.status_code != 200: - return [r.text], False + return [f"Status {r.status_code}: {r.text}"], False response = json.loads(r.text) @@ -686,7 +687,7 @@ def quote_v2(self, tickers): self.update_token(token_type='update') r = requests.post(urls.ticker_quotes_v2(), json=data, headers=self.headers) if r.status_code != 200: - return [r.text], False + return [f"Status {r.status_code}: {r.text}"], False response = json.loads(r.text) return response["quotes"], True @@ -704,7 +705,7 @@ def orders_v2(self, account_id=None): self.headers["schwab-client-account"] = str(account_id) r = requests.get(urls.orders_v2(), headers=self.headers) if r.status_code != 200: - return [r.text], False + return [f"Status {r.status_code}: {r.text}"], False response = json.loads(r.text) return response["Orders"] @@ -820,7 +821,7 @@ def get_options_chains_v2(self, ticker, greeks=False): self.update_token(token_type='update') r = requests.get(full_url, headers=self.headers) if r.status_code != 200: - return [r.text], False + return [f"Status {r.status_code}: {r.text}"], False response = json.loads(r.text) return response From b1f01eeb004907ebbf4892f2a3a0352ea1eccb64 Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Thu, 29 Jan 2026 16:37:09 -0800 Subject: [PATCH 17/18] Another behind-the-scenes Schwab change to deal with. --- schwab_api/schwab.py | 7 +++++-- schwab_api/urls.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index 2da4a2d..f418f75 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -705,15 +705,18 @@ def orders_v2(self, account_id=None): self.headers["schwab-client-account"] = str(account_id) r = requests.get(urls.orders_v2(), headers=self.headers) if r.status_code != 200: - return [f"Status {r.status_code}: {r.text}"], False + return [f"Status {r.status_code}: {r.text}"] response = json.loads(r.text) return response["Orders"] # Now raises urllib.error.HTTPError: HTTP Error 400: Bad Request: "Account number is required." - def get_account_info_v2_broken(self): + def get_account_info_v2(self): account_info = dict() self.update_token(token_type='api') + # somewhere around 2025-08-25, this change became necessary + if 'schwab-client-account' in self.headers: + self.headers['Schwab-Client-Ids'] = self.headers['schwab-client-account'] r = requests.get(urls.positions_v2(), headers=self.headers) if r.status_code >= 400: raise urllib.error.HTTPError(r.url, r.status_code, f"{r.reason}: {r.text}", r.request.headers, None) diff --git a/schwab_api/urls.py b/schwab_api/urls.py index a801b26..bc85264 100644 --- a/schwab_api/urls.py +++ b/schwab_api/urls.py @@ -37,6 +37,7 @@ def option_chains_v2(): return "https://ausgateway.schwab.com/api/is.CSOptionChainsWeb/v1/OptionChainsPort/OptionChains/chains" # Old API +# Now only returns a single account def positions_data(): return "https://client.schwab.com/api/PositionV2/PositionsDataV2" From 68a94c912ca414b8df4b71a173337c8e9e9122fc Mon Sep 17 00:00:00 2001 From: Virgil Bourassa Date: Tue, 7 Apr 2026 10:48:50 -0600 Subject: [PATCH 18/18] Added the duration codes for Fill-or-Kill and Immediate-or-Cancel limit orders to the trade_v2 documentation. --- schwab_api/schwab.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index f418f75..b25bb1f 100644 --- a/schwab_api/schwab.py +++ b/schwab_api/schwab.py @@ -248,6 +248,8 @@ def trade_v2(self, tested is value 48 mapping to Day-only orders. 48 - Day 49 - GTC Good till canceled + 51 - Immediate-or-cancel + 52 - Fill-or-kill 201 - Day + extended hours limit_price (number) - The limit price to set with the order, if necessary. stop_price (number) - The stop price to set with the order, if necessary.