Skip to content

Commit

Permalink
Merge pull request itsjafer#49 from 4rumprom/main
Browse files Browse the repository at this point in the history
Adding option strategies support
  • Loading branch information
itsjafer authored Mar 5, 2024
2 parents e680451 + 6ff0282 commit 8b011a2
Show file tree
Hide file tree
Showing 4 changed files with 310 additions and 2 deletions.
83 changes: 83 additions & 0 deletions example/example_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from schwab_api import Schwab
from dotenv import load_dotenv
import os
import pandas as pd
import pprint

load_dotenv()

username = os.getenv("SCHWAB_USERNAME")
password = os.getenv("SCHWAB_PASSWORD")
totp_secret = os.getenv("SCHWAB_TOTP")

# Initialize our schwab instance
api = Schwab()

# Login using playwright
print("Logging into Schwab")
logged_in = api.login(
username=username,
password=password,
totp_secret=totp_secret # Get this using itsjafer.com/#/schwab.
)

# Get quotes for options.
option_chain = api.get_options_chains_v2('$RUT') #try also with parameter greeks = True

# The json output is deeply nested so here is how you can work with it:
# Normalizing the data into a pandas DataFrame
df1 = pd.json_normalize(option_chain,['Expirations','Chains','Legs'],[['Expirations','ExpirationGroup']])
# Normalizing Expirations.ExpirationGroup
df2 = pd.json_normalize(df1['Expirations.ExpirationGroup'])
# Dropping the column Expirations.ExpirationGroup in df1 and concatenating the two dataframes (side by side)
df1.drop('Expirations.ExpirationGroup',axis=1, inplace=True)
df = pd.concat([df1,df2],axis=1)
# Converting strings to numbers when relevant. Keeping strings is conversion is not possible.
df = df.apply(lambda col: pd.to_numeric(col, errors='coerce')).fillna(df)

# Let's isolate options with closest expiration date:
closest_expiration_options = df[(df.DaysUntil==df.DaysUntil.min())]

# Let's find the call and put options with closest strike price to current price:
# First let's grab the current price. No need to use api.quote_v2(), it's already in chains
current_price = float(option_chain['UnderlyingData']['Last'])
# Finding the index of the closest strike prices
ATM_call_index = abs(closest_expiration_options[closest_expiration_options.OptionType=="C"].Strk - current_price).idxmin()
ATM_put_index = abs(closest_expiration_options[closest_expiration_options.OptionType=="P"].Strk - current_price).idxmin()
# Grabbing the rows at those indexes
ATM_call_option = closest_expiration_options.iloc[ATM_call_index]
ATM_put_option = closest_expiration_options.iloc[ATM_put_index]
print(f"Call and Put ATM options (At The Money) with the closest expiration:")
print(f"Call: {ATM_call_option.Sym} Ask: {ATM_call_option.Ask} Bid: {ATM_call_option.Bid}")
print(f"Put: {ATM_put_option.Sym} Ask: {ATM_put_option.Ask} Bid: {ATM_put_option.Bid}")

# Now let's place an at the money straddle for the closest expiration date
# Preparing the parameters
# Setting the straddle strategy code:
strategy = 226 # for more codes, look at the comment section of option_trade_v2().
symbols = [ATM_call_option.Sym,ATM_put_option.Sym]
instructions = ["BTO","BTO"] #Buy To Open. To close the position, it would be STC (Sell To Close)
quantities = [1,1]
# Note that the elements are paired. So the first symbol of the list will be associated with the first element of instructions and quantities.
account_info = api.get_account_info_v2()
account_id = next(iter(account_info))
order_type = 202 #net debit. 201 for net credit. You probably should avoid 49 market with options...
# Let's set the limit price at the median between bid and ask.
limit_price = (ATM_call_option.Ask + ATM_call_option.Bid + ATM_put_option.Ask + ATM_put_option.Bid) / 2
# Let's place the trade:
messages, success = api.option_trade_v2(
strategy=strategy,
symbols = symbols,
instructions=instructions,
quantities=quantities,
account_id=account_id,
order_type = order_type,
dry_run=True,
limit_price = limit_price
)

print("The order verification was " + "successful" if success else "unsuccessful")
print("The order verification produced the following messages: ")
pprint.pprint(messages)

# Happy coding!!
3 changes: 2 additions & 1 deletion example/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
schwab_api
python-dotenv==0.16.0
python-dotenv==0.16.0
pandas==2.2.0
223 changes: 222 additions & 1 deletion schwab_api/schwab.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ def trade_v2(self,
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.
primary_security_type (int) - The type of the security being traded.
46 - For stocks and funds.
48 - For options. For option strategies, use option_trade_v2()
49 - For mutual fund - not supported. Requires different parameters.
valid_return_codes (set) - Schwab returns an orderReturnCode in the response to both
the verification and execution requests, and it appears to be the
"severity" for the highest severity message.
Expand Down Expand Up @@ -306,7 +309,6 @@ def trade_v2(self,
"AccountColor":0
},
"OrderStrategy": {
# Unclear what the security types map to.
"PrimarySecurityType":primary_security_type,
"CostBasisRequest": {
"costBasisMethod":costBasis,
Expand Down Expand Up @@ -385,6 +387,198 @@ 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
):
"""
Disclaimer:
Use at own risk.
Make sure you understand what you are doing when trading options and using this function.
Option trading requires an application and approval process at Schwab.
strategy (int) - Type of options strategy:
2 leg strategies:
201 - vertical call spread
202 - vertical put spread
203 - calendar call spread (level 3)
204 - calendar put spread (level 3)
205 - diagonal call spread
206 - diagonal put spread
207 - ratio call spread (level 3)
208 - ratio put spread (level 3)
217 - custom 2 legs:
3 leg strategies:
209 - Butterfly call spread
210 - Butterfly put spread
218 - custom 3 legs
4 leg strategies:
211 - condor call spread
212 - condor put spread
214 - iron condor
219 - custom 4 legs
Combinations:
226 - straddle
227 - strangle
symbols (list of str) - List of the contracts you want to trade, each element being a leg of the trade,
instructions (list str) - is a list containing the instructions for each leg
"BTO" - Buy to open
"BTC" - Buy to close
"STO" - Sell to open
"STC" - Sell to close
quantities (list int) - The amount of contracts to buy/sell for each symbol / contract,
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!
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.
48 - Day
49 - GTC Good till canceled
limit_price (number) - The limit price to set with the order. Usage recommended.
stop_price (number) - The stop price to set with the order, if necessary.
Not sure when to use this. Never tested.
valid_return_codes (set) - Schwab returns an orderReturnCode in the response to both
the verification and execution requests, and it appears to be the
"severity" for the highest severity message.
Verification response messages with severity 10 include:
- The market is now closed. This order will be placed for the next
trading day
- You are purchasing an ETF...please read the prospectus
- It is your responsibility to choose the cost basis method
appropriate to your tax situation
- 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)
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.
For the execution response, the orderReturnCode is typically 0 for a
successfully placed order.
Execution response messages with severity 30 include:
- Order Affirmation required (This means Schwab wants you to confirm
that you really meant to place this order as-is since something about
it meets Schwab's criteria for requiring verification. This is
usually analogous to a checkbox you would need to check when using
the web interface)
affirm_order (bool) - Schwab requires additional verification for certain orders, such
as when a limit order is executable, or when buying some commodity ETFs.
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.
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 not (len(quantities) == len(symbols) and len(symbols) == len(instructions)):
raise ValueError("variables quantities, symbols and instructions must have the same length")

instruction_code = {
"BTO": "201",
"BTC": "202",
"STO": "203",
"STC": "204"
}
instruction_codes = [instruction_code[i] for i in instructions]

self.update_token(token_type='update')

data = {
"UserContext": {
"AccountId": str(account_id),
"AccountColor": 0
},
"OrderStrategy": {
"PrimarySecurityType": 48,
"CostBasisRequest": None,
"OrderType": str(order_type),
"Duration": str(duration),
"LimitPrice": str(limit_price),
"StopPrice": str(stop_price),
"ReinvestDividend": False,
"MinimumQuantity": 0,
"AllNoneIn": False,
"DoNotReduceIn": False,
"Strategy": strategy,
"OrderStrategyType": 1,
"OrderLegs": [
{
"Quantity": str(qty),
"LeavesQuantity": str(qty),
"Instrument": {"Symbol": symbol},
"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
}

# 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

response = json.loads(r.text)

orderId = 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"]

messages = list()
for message in response["orderStrategy"]["orderMessages"]:
messages.append(message["message"])

# TODO: This needs to be fleshed out and clarified.
if response["orderStrategy"]["orderReturnCode"] not in valid_return_codes:
return messages, False

if dry_run:
return messages, True

# Make the same POST request, but for real this time.
data["UserContext"]["CustomerId"] = 0
data["OrderStrategy"]["OrderId"] = int(orderId)
data["OrderProcessingControl"] = 2
if affirm_order:
data["OrderStrategy"]["OrderAffrmIn"] = True
self.update_token(token_type='update')
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)

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"])

if response["orderStrategy"]["orderReturnCode"] in valid_return_codes:
return messages, True

return messages, False

def cancel_order_v2(
self, account_id, order_id,
# The fields below are experimental and should only be changed if you know what
Expand Down Expand Up @@ -571,6 +765,33 @@ 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):
"""
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.
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,
"IncludeGreeks": "true" if greeks else "false"
}

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'

self.update_token(token_type='update')
r = requests.get(full_url, headers=self.headers)
if r.status_code != 200:
return [r.text], False

response = json.loads(r.text)
return response

def update_token(self, token_type='api'):
r = self.session.get(f"https://client.schwab.com/api/auth/authorize/scope/{token_type}")
if not r.ok:
Expand Down
3 changes: 3 additions & 0 deletions schwab_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ def transaction_history_v2():
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"
Expand Down

0 comments on commit 8b011a2

Please sign in to comment.