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/authentication.py b/schwab_api/authentication.py index 905ab45..be6d0e8 100644 --- a/schwab_api/authentication.py +++ b/schwab_api/authentication.py @@ -13,7 +13,9 @@ # Constants -USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{version}) Gecko/20100101 Firefox/" +USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Firefox/" +# USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{version}) Gecko/20100101 Firefox/" + VIEWPORT = { 'width': 1920, 'height': 1080 } class SessionManager: diff --git a/schwab_api/schwab.py b/schwab_api/schwab.py index d4f8e58..5578c33 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): """ @@ -118,16 +119,20 @@ 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) - def trade(self, ticker, side, qty, account_id, dry_run=True): + # 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 symbol you want to trade, + ticker (str) - The ticker symbol 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, + 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) """ @@ -139,24 +144,27 @@ def trade(self, ticker, side, qty, account_id, dry_run=True): else: raise Exception("side must be either Buy or Sell") + cost_basis_method = 'BTAX' if tax_optimized_cost_basis else 'FIFO' + 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", - } + "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": + data["IsReinvestDividends"] = reinvest 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) @@ -167,13 +175,15 @@ def trade(self, ticker, side, qty, account_id, dry_run=True): if dry_run: return messages, True + short_description = urllib.parse.quote_plus(response['IssueShortDescription']) \ + if response['IssueShortDescription'] is not None else '' data = { "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'], @@ -181,7 +191,7 @@ def trade(self, ticker, side, qty, account_id, dry_run=True): "OrderType": "Market", "Principal": response['QuoteAmount'], "Quantity": str(qty), - "ShortDescription": urllib.parse.quote_plus(response['IssueShortDescription']), + "ShortDescription": short_description, "Symbol": response["IssueSymbol"], "Timing": "Day Only" } @@ -189,31 +199,36 @@ def trade(self, ticker, side, qty, account_id, dry_run=True): 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) + for message in response["Messages"]: + messages.append(message["Message"]) + if response["ReturnCode"] == 0: return messages, True 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, - 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.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', @@ -233,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 (round lots of hundreds of shares) + 52 - Fill-or-kill (round lots of hundreds of shares within security's quoted spread) 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. @@ -277,12 +294,16 @@ 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. Returns messages (list of strings), is_success (boolean) """ + if valid_return_codes is None: + valid_return_codes = {0, 10} if side == "Buy": buySellCode = "49" elif side == "Sell": @@ -297,52 +318,56 @@ 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 + if side == "Buy": + 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=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) @@ -355,7 +380,7 @@ def trade_v2(self, 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: @@ -374,37 +399,35 @@ 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) - 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 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. @@ -465,6 +488,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. @@ -486,6 +510,8 @@ def option_trade_v2(self, 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") @@ -500,11 +526,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), @@ -525,7 +551,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 } @@ -535,7 +561,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) @@ -566,7 +592,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) @@ -585,8 +611,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 @@ -605,12 +631,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: @@ -648,10 +674,13 @@ 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, - "AccountRegType":"S3" + "Symbols": tickers, + "IsIra": False, + "AccountRegType": "S3" } # Adding this header seems to be necessary. @@ -660,10 +689,10 @@ 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"] + return response["quotes"], True def orders_v2(self, account_id=None): """ @@ -675,18 +704,25 @@ 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 + 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(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=}", + r.request.headers, None) response = json.loads(r.text) for account in response['accounts']: positions = list() @@ -694,20 +730,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( @@ -767,7 +805,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. @@ -777,11 +815,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' @@ -789,7 +827,68 @@ 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 + + def get_balances_and_positions(self, account_id): + """ + Returns a dict with the following structure: + { + "brokerageAccountId": 12345678, + "accountType": 1, + "dayTradeCount": 0, + "isPatternDayTrader": false, + "pendingPurchasesEnabled": true, + "showPendingPurchasesBanner": false, + "openOrdersIncluded": true, + "balanceDetails": { + "availableToTradeBalances": { + "cash": 99.99, + "settledFunds": 99.99, + "mutualFunds": 99.99, + "netWorth": 9999.99 + } + }, + "positionDetails": { + "positions": [ + { + "symbol": "FIX", + "displaySymbol": null, + "symbolDescription": "COMFORT SYS USA INC", + "securityType": "Stock/ETF", + "itemIssueId": 123456789, + "shares": 9.999, + "shortShares": 0.0, + "totalShares": 9.999, + "totalOptions": 0.0, + "reinvestDividend": true, + "reinvestCapitalGains": false + }, + { + "symbol": "MU", + "displaySymbol": null, + "symbolDescription": "MICRON TECHNOLOGY INC", + "securityType": "Stock/ETF", + "itemIssueId": 987654321, + "shares": 99.0, + "shortShares": 0.0, + "totalShares": 99.0, + "totalOptions": 0.0, + "reinvestDividend": false, + "reinvestCapitalGains": false + } + ] + } + } + """ + self.headers['schwab-resource-version'] = '1.0' + self.update_token("api") + self.headers["schwab-client-account"] = str(account_id) + r = requests.get(urls.balances_positions_v2(), params={'account': 'BROKERAGE'}, 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=}", + r.request.headers, None) + response = json.loads(r.text) + return response diff --git a/schwab_api/urls.py b/schwab_api/urls.py index ae39297..860427c 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" @@ -36,7 +36,11 @@ def lot_details_v2(): def option_chains_v2(): return "https://ausgateway.schwab.com/api/is.CSOptionChainsWeb/v1/OptionChainsPort/OptionChains/chains" +def balances_positions_v2(): + return "https://ausgateway.schwab.com/api/is.TradeOrderManagementWeb/v1/TradeOrderManagementWebPort/account/balancespositions" # ?account=BROKERAGE + # Old API +# Now only returns a single account def positions_data(): return "https://client.schwab.com/api/PositionV2/PositionsDataV2"