Trading 0DTE Options with the IBKR Native API

Here’s a thing that I suspect will make money, but that I haven’t yet tested (for reasons that I will explain shortly):

  • Every day, at the start of the trading day, get the SPX straddle price and convert it to an expected SPX price move.
  • Then at the end of the trading day, take the SPX price and calculate if it moved more or less than the straddle implied.
  • Aggregate this over a few days – the simplest way would be to take the average expected move and the average actual move.
  • The trading signal for the next day’s open is as follows:
    • If SPX moved more than was implied by the straddle on average over the last few days, buy the straddle.
    • If SPX moved less than implied on average, sell the straddle.

That is:

if mean(actual_move) > mean(expected_move):
  buy tomorrow's 0DTE ATM straddle
else if mean(actual_move) < mean(expected_move):
  sell tomorrow's 0DTE ATM straddle

It’s essentially fitting to how well the options market predicted spoos volatility in the very short term.

I think there’s probably an edge here, but I’m also concerned about costs, since the cost of trading a 0DTE straddle will be a large percentage of its return, most of the time.

Why haven’t I tested this properly?

Because the data is hard to come by. I didn’t have it at hand, and I couldn’t get enough from the usual free sources to test it properly.

When I have a suspicion about a possible edge, or someone tells me about an edge they have a hunch about (which is where this idea came from), I like to move fast and test it out with minimal investment in time and money. Most of the time, these things don’t work out, and so I want to disprove ideas quickly and move on. To do that, you need ready access to data.

But the data for this idea is a little awkward. Specifically, you need historical ATM SPXW options daily open and close prices (which I don’t have). And most of the usual sources (including IBKR) don’t provide data for expired contracts.

What to do instead?

You have some options. You could go and buy the data and test it properly. Or, you could just start trading it in small size and collect the data you need along the way.

In this case, I’m thinking about doing the latter. I like this approach because I learn a lot about the strategy by actually executing it. And if it has no edge, then the expected costs are just the trading fees.

Also, I promised I’d show you some 0DTE options stuff with the IB Native API! So here goes.

In this article, I’ll show you how to calculate the signal for this strategy and place the appropriate trades via the IB Native API.

Straddle prices and expected price moves

The price of the ATM straddle allows you to estimate the expected move in the underlying that the options are pricing in. The generally accepted formula, which is just an estimate, is:

ΔPu=0.85Ps\Delta P_u = 0.85 * P_s

where PuP_u is the price of the unerlying, and PsP_s is the price of the ATM straddle.

For example say our underlying was priced at 100.TheATMstraddlecosts100. The ATM straddle costs 15. The expected move is therefore 0.85*15 = 12.75.Thatmeansthatatexpiryofthestraddle,weexpectpricetobewithin100+/12.75,thatisbetween12.75.

That means that at expiry of the straddle, we expect price to be within 100 +/- 12.75, that is between 87.25 and $112.75.

Data requirements

For this strategy, we need the following data:

  • Daily open and close SPX prices (the open price will be our straddle strike price, and the close will allow us to calculate the actual price move)
  • Daily 0DTE ATM call and put option open prices

Assuming we have the appropriate market data subscriptions, we can get daily SPX prices directly out of TWS.

Options data is more problematic.

Even if you have the appropriate subscriptions, you won’t be able to get expired options contracts out of TWS. And we need a few days’ worth of expired contracts to calculate our trading signal.

So we’ll get that data from Yahoo Finance instead.

You can get XPSW options chains from Yahoo at this url:

And you can get price data for options that haven’t yet expired by selecting the expiry date from the dropdown:

I did some experimenting and found that you could also get price data for recently expired options using the following pattern for the URL:{expiry}{contract_type}0{strike*1000}

where contract_type is “C” or “P” for call and put respectively, and expiry is of the format yymmdd.

Here’s an example of what’s available for an option that expired yesterday:

We have the open price on expiry day in the table, and I think the number in large bold is the last traded price on expiry day. I don’t know whether the open price represents the mid price, the bid, the ask, the first trade, or something else.

Unfortunately it seems Yahoo only makes a few days’ worth of contracts available. I could find nothing older than about five days. So this data source won’t be useful for backtesting, but maybe we can use it for calculating our trade signal.

A strategy architecture

Given our data requirements, we can craft a trading application using the IB Native API that consists of the following:

  • Connect to TWS
  • Request four (say) days of SPX price data from TWS
  • Using SPX open prices as our strike prices, get SPXW call and put open and last traded prices on expiry day from Yahoo for the prior four days
  • Calculate the ATM 0DTE straddle price and implied expected move for each of the four days
  • Calculate the actual move from the open and close prices of SPX for each of the four days
  • Calculate the mean expected and actual moves over the four days
  • Calculate the trade signal
  • Place the appropriate trades in TWS

Strategy implementation

Following is some Python code for implementing this strategy.

The main purpose is to demonstrate how to trade options using the IB Native API. If you do trade this strategy, be aware of the following caveats:

  • I don’t have confidence in it at the moment – it’s only based on a hunch
  • The options data source is dubious (I don’t even have confidence that the data represents what I think it does)

With that out of the way, here’s the code. I’ve heavily commented it so that you can follow along if this is new to you.

For an introduction to the IB Native API, read this article first.

I ran this shortly after Friday’s open (22 March 2024). You would need to make some modifications to run it in the future (for example update the start_date and end_date parameters that control the SPX price data download) etc.

Also, I’ve only included the bare minimum you need to get started with a strategy such as this. In particular, I’ve only included the minimum order handling logic required to trade a straddle using limit orders at the current bid/ask price. I’ve not made any attempt to handle cases where the order isn’t filled. I’ve also not included handling of any edge cases (for example, where an fails to be received by TWS).

# Strategy for trading 0DTE straddles via the IBKR native API
# Buy/sell the 0DTE ATM straddle if recent 0DTE straddles under/over-predicted the day's spoos move

from threading import Thread, Event
import time
from typing import Any
from ibapi.common import BarData
from ibapi.wrapper import EWrapper
from ibapi.client import EClient
from ibapi.contract import Contract, ContractDetails
from ibapi.order import *
from ibapi.common import *
from ibapi.account_summary_tags import AccountSummaryTags
import pandas as pd
import requests
from bs4 import BeautifulSoup

# make url for 0DTE ATM option contracts from yahoo
# can only get approx 5 days' worth of expired contracts
def make_url(expiry, contract_type, strike):
    return f"{expiry}{contract_type}0{strike*1000}"

# get 0dte options prices from yahoo
# use beautiful soup to parse the HTML and extract values from relevant tags
def get_0dte_prices(expiry, contract_type, strike):
    url = make_url(expiry, contract_type, strike)

    # headers to simulate browser request
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36"

    # get web page
    response = requests.get(url, headers=headers)

    # parse
    soup = BeautifulSoup(response.text, "html.parser")

    # extract last traded price (HTML specification is from inspection of Yahoo options data pages)
    last_traded_price_tag = soup.find(
        "fin-streamer", {"data-test": "qsp-price", "data-field": "regularMarketPrice"}
    last_traded_price = (
        float(last_traded_price_tag["value"]) if last_traded_price_tag else "Not found"

    # extract open price (HTML specification is from inspection of Yahoo options data pages)
    open_price_tag = soup.find("td", {"data-test": "OPEN-value"})
    open_price = float(open_price_tag.text) if open_price_tag else "Not found"

    return (open_price, last_traded_price)

# class for trading the straddles strategy
class ibStraddlesApp(EClient, EWrapper):
    def __init__(self, ticker):
        EClient.__init__(self, self)
        self.ticker = ticker
        self.next_req_id = 0  # keep track of request ids
        self.connection_ready = Event()  # to signal the connection has been established
        self.done = (
        )  # for signalling between threads when a message has been fully processed
        self.historical_data = []  # for storing historical data
        self.price = {}  # for storing current option contract price data

    def increment_req_id(self):
        self.next_req_id += 1

    # override Ewrapper.error
    def error(
        self, reqId: TickerId, errorCode: int, errorString: str, contract: Any = None
        print("Error: ", reqId, " ", errorCode, " ", errorString)
        if errorCode == 502:
            # not connected
            # set self.done (a threading.Event) to True

    # override Ewrapper.nextValidID - used to signal that the connection between application and TWS is complete, and
    # returns the next valid orderID (for any future transactions).
    # if we send messages before the connection has been established, they can be lost
    # so wait for this method to be called
    def nextValidId(self, orderId: int):
        self.nextorderId = orderId
        print(f"Connection ready, next valid order ID: {orderId}")
        self.connection_ready.set()  # signal that the connection is ready

    # override Ewrapper.contractDetails
    # gets back contract details
    def contractDetails(self, reqId: int, contractDetails: ContractDetails):
        super().contractDetails(reqId, contractDetails)

    # override Ewraper.contractDetailsEnd
    # signals that the contract details request has completed
    def contractDetailsEnd(self, reqId: int):
        print(f"Contract details request {reqId} complete")

    # override Ewrapper.historicalData
    # TWS sends data from reqHistoricalData to this callback
    def historicalData(self, reqId: int, bar: BarData):
        d = {
            "Ticker": self.ticker,
            "High": bar.high,
            "Low": bar.close,
            "Close": bar.close,

    # override EWrapper.historicalDataEnd is received
    # signals that historical market data request is complete
    # (note only sent if reqHistoricalData invoked with keepUpToDate=False)
    def historicalDataEnd(self, reqId: int, start: str, end: str):
        print(f"Historical data request complete")

    # callback for handling current price data requests
    # see for available tickTypes
    def tickPrice(self, reqId, tickType, price, attrib):
        """Key self.price with the reqId that made the request so that you can keep track of which item belongs to which contract.
        See usage below for example.
        if not reqId in self.price.keys():
            self.price[reqId] = {}

        # handle bid price (tickType = 1)
        if tickType == 1:
            print("The current bid price is: ", price)
            self.price[reqId]["bid"] = price

        # handle ask price (tickType = 2)
        if tickType == 2:
            print("The current ask price is: ", price)
            self.price[reqId]["ask"] = price

    # override Ewrapper.tickSnapshotEnd
    # signals snapshot request is complete
    def tickSnapshotEnd(self, reqId: int):
        print(f"Current price data request complete")

    # override Ewrapper order callbacks - just print execution and order status to screen
    # in practice, you would monitor these and handle appropriately (eg if order wasn't filled)
    def orderStatus(
            "orderStatus - orderid:",

    def openOrder(self, orderId, contract, order, orderState):
            "openOrder id:",

    def execDetails(self, reqId, contract, execution):
            "Order Executed: ",

# define our event loop - this will run in its own thread
def run_loop(app):

##### Instantiate a trading app and wait for a successful connection

# instantiate an ibStraddlesApp
# ticker of the underlying
app = ibStraddlesApp(ticker="SPX")

# connect
# clientID identifies our application
app.connect("", 7496, clientId=0)

# start the application's event loop in a thread
api_thread = Thread(target=run_loop, args=(app,), daemon=True)

# wait until the Ewrapper.nextValidId callback is triggered, indicating a successful connection

##### Get SPX data required for trading signal

# required date range - will need to update this
start_date = "20240318"
end_date = "20240322"
date_range = pd.date_range(start=start_date, end=end_date)
num_days = len(date_range)

# get recent SPX data
spx = Contract()
spx.symbol = "SPX"
spx.secType = "IND"
spx.currency = "USD" = "CBOE"

# request historical SPX data and increment request id
    # 8:30 chicago is 9:30 NY
    endDateTime=f"{end_date} 16:00:00 US/Central",
    durationStr=f"{num_days} D",
    barSizeSetting="1 day",

# wait for historical data to come back before continuing

# reset event

# make dataframe from receieved SPX data
spx_prices = pd.DataFrame(app.historical_data)

# save to disk
# spx_prices.to_csv("./ibkr-api/spx_prices.csv")

print("spx price data:")

# check you have the expected data:
if len(spx_prices["Date"]) < 5:
    raise Exception("Missing SPX data, stopping")

##### Get options data and calculate trading signal

# get historical ATM 0DTE XPSW options prices from Yahoo
expected_moves = []
actual_moves = []
for d in spx_prices["Date"][:-1]:  # don't get today's data yet
    # get ATM strike from each day's SPX open price
    spx_open = spx_prices.loc[spx_prices["Date"] == d, "Open"].iloc[0]

    # round to nearest $5
    strike = round(spx_open / 5) * 5

    # get epxiry from date string
    expiry = str(d)[2:]

    # get put open and last traded prices
    p_open, p_close = get_0dte_prices(expiry=expiry, contract_type="P", strike=strike)

    # if we didn't find an ATM strike:
    # a bit of hack to get the nearest existing strike
    # in reality you'd be more careful
    while p_open == "Not found":
        strike -= 5
        p_open, p_close = get_0dte_prices(
            expiry=expiry, contract_type="P", strike=strike

    # get call open and last traded prices
    c_open, c_close = get_0dte_prices(expiry=expiry, contract_type="C", strike=strike)

    # calculate straddle open and last traded prices
    straddle_open_price = p_open + c_open

    # calculate straddle-implied expected move and store
    expected_move = 0.85 * straddle_open_price

    # calculate actual move from SPX prices and store
    spx_close = spx_prices.loc[spx_prices["Date"] == d, "Close"].iloc[0]
    actual_move = abs(spx_close - spx_open)

    print("Expected move, actual move:")
    print(expected_move, actual_move)

# calculate recent average expected and actual moves
ave_expected_move = sum(expected_moves) / len(expected_moves)
ave_actual_move = sum(actual_moves) / len(actual_moves)
print("Average expected move, average actual move")
print(ave_expected_move, ave_actual_move)

##### Prepare contract objects and get current prices for trading

# get today's ATM strike and expiry
today_open = spx_prices["Open"].iloc[-1]
strike = round(today_open / 5) * 5
expiry = spx_prices["Date"].iloc[-1]
print(f"Today's strike:{strike}, today's expiry: {expiry}")

# create an options contract representing today's 0DTE ATM call
call = Contract()
call.symbol = "SPXW"
call.secType = "OPT"
call.currency = "USD" = "SMART"
call.lastTradeDateOrContractMonth = expiry
call.strike = strike
call.right = "C"
call.tradingClass = "SPXW"

# keep track of the reqId for the call
call_req_id = app.next_req_id

# get the call's current bid-ask prices
print("Current call prices:")
    genericTickList="",  # get all available data
    snapshot=True,  # requires appropriate market data subscription

# call prices:
# note app.price is keyed by reqId

# create an options contract representing today's 0DTE ATM put
put = Contract()
put.symbol = "SPXW"
put.secType = "OPT"
put.currency = "USD" = "SMART"
put.lastTradeDateOrContractMonth = expiry
put.strike = strike
put.right = "P"
put.tradingClass = "SPXW"

# keep track of the reqId for the put
put_req_id = app.next_req_id

# get the put's current bid-ask prices
print("Current put prices:")
    genericTickList="",  # get all available data types
    snapshot=True,  # requires appropriate market data subscription

# put prices:
# note app.price is keyed by reqId

##### Trade logic

# Now we have call and put prices for today, we can shoot off limit orders at these prices
# I prefer this to shooting off a market order into a potentially illiquid product
# You can even add a buffer to your limit order beyond the current top of book price to give you more chance of getting filled
# Many ways to do this - up to you.
# But in practice, you'll need to manage your orders and fills - you might end up chasing the market

# sell today's 0DTE ATM straddle if SPX has moved less than implied by recent straddle prices
if ave_actual_move < ave_expected_move:
    # sell today's 0DTE stradle
    # sell call:
    print("Selling today's 0DTE ATM straddle")
    order = Order()
    order.action = "SELL"
    order.totalQuantity = 1  # note will be multiplied by contract multiplier
    order.orderType = "LMT"
    order.lmtPrice = app.price[call_req_id]["bid"]  # limit sell the call at the bid
    order.transmit = True
    app.placeOrder(app.nextorderId, call, order)
    app.nextorderId += 1

    # sell put:
    order = Order()
    order.action = "SELL"
    order.totalQuantity = 1  # note will be multiplied by contract multiplier
    order.orderType = "LMT"
    order.lmtPrice = app.price[put_req_id]["bid"]  # limit sell the put at the bid
    order.transmit = True
    app.placeOrder(app.nextorderId, put, order)
    app.nextorderId += 1
# buy today's 0DTE ATM straddle if SPX has moved more than implied by recent straddle prices
    # buy today's 0DTE stradle
    # buy call:
    print("Buying today's 0DTE ATM straddle")
    order = Order()
    order.action = "BUY"
    order.totalQuantity = 1  # note will be multiplied by contract multiplier
    order.orderType = "LMT"
    order.lmtPrice = app.price[call_req_id]["ask"]  # limit buy the call at the ask
    order.transmit = True
    app.placeOrder(app.nextorderId, call, order)
    app.nextorderId += 1

    # buy put:
    order = Order()
    order.action = "BUY"
    order.totalQuantity = 1  # note will be multiplied by contract multiplier
    order.orderType = "LMT"
    order.lmtPrice = app.price[put_req_id]["ask"]  # limit buy the put at the ask
    order.transmit = True
    app.placeOrder(app.nextorderId, put, order)
    app.nextorderId += 1

# You'll want to handle the order as well - maybe cancel it if the market runs away for instance.
# When placing orders via the API and building a robust trading system, it is important to monitor for callback notifications,
# specifically for IBApi::EWrapper::error, IBApi::EWrapper::orderStatus changes, IBApi::EWrapper::openOrder warnings, and
# IBApi::EWrapper::execDetails to ensure proper operation.
# Here we just use these methods to print execution details to screen.

# disconnect once orders handled satisfactorily
# commented here for simplicity since we didn't do any order handling
# app.disconnect()

Here’s the output from running this code shortly after Friday’s open:

Connection ready, next valid order ID: 16
Error:  -1   2104   Market data farm connection is OK:usfarm.nj
Error:  -1   2104   Market data farm connection is OK:usfuture
Error:  -1   2104   Market data farm connection is OK:usopt
Error:  -1   2104   Market data farm connection is OK:usfarm
Error:  -1   2106   HMDS data farm connection is OK:euhmds
Error:  -1   2106   HMDS data farm connection is OK:cashhmds
Error:  -1   2106   HMDS data farm connection is OK:fundfarm
Error:  -1   2106   HMDS data farm connection is OK:ushmds
Error:  -1   2158   Sec-def data farm connection is OK:secdefnj
Historical data request complete
spx price data:
  Ticker      Date     Open     High      Low    Close
0    SPX  20240318  5154.77  5175.60  5149.42  5149.42
1    SPX  20240319  5139.09  5180.31  5178.51  5178.51
2    SPX  20240320  5181.69  5226.19  5224.62  5224.62
3    SPX  20240321  5253.43  5261.10  5241.53  5241.53
4    SPX  20240322  5242.48  5244.21  5241.18  5244.18
Expected move, actual move:
32.852500000000006 5.350000000000364
Expected move, actual move:
22.8905 39.42000000000007
Expected move, actual move:
31.517999999999997 42.93000000000029
Expected move, actual move:
21.504999999999995 11.900000000000546
Average expected move, average actual move
27.1915 24.90000000000032
Today's strike:5240, today's expiry: 20240322
Current call prices:
The current bid price is:  16.43
The current ask price is:  16.45
Current price data request complete
{'bid': 16.43, 'ask': 16.45}
Current put prices:
The current bid price is:  12.44
The current ask price is:  12.49
Current price data request complete
{'bid': 12.44, 'ask': 12.49}
Selling today's 0DTE ATM straddle
openOrder id: 17 SPX OPT @ SMART : SELL LMT 1 PreSubmitted
orderStatus - orderid: 17 status: PreSubmitted filled 0 remaining 1 lastFillPrice 0.0
Order Executed:  -1 SPX OPT USD 00020057.65fd0c22.01.01 12 1 2
openOrder id: 17 SPX OPT @ SMART : SELL LMT 1 Filled
orderStatus - orderid: 17 status: Filled filled 1 remaining 0 lastFillPrice 16.43
openOrder id: 17 SPX OPT @ SMART : SELL LMT 1 Filled
orderStatus - orderid: 17 status: Filled filled 1 remaining 0 lastFillPrice 16.43
openOrder id: 18 SPX OPT @ SMART : SELL LMT 1 Submitted
orderStatus - orderid: 18 status: Submitted filled 0 remaining 1 lastFillPrice 0.0
Order Executed:  -1 SPX OPT USD 00020057.65fd0c2b.01.01 13 1 1
openOrder id: 18 SPX OPT @ SMART : SELL LMT 1 Filled
orderStatus - orderid: 18 status: Filled filled 1 remaining 0 lastFillPrice 12.44
openOrder id: 18 SPX OPT @ SMART : SELL LMT 1 Filled
orderStatus - orderid: 18 status: Filled filled 1 remaining 0 lastFillPrice 12.44

You can see that the strategy successfully connected to TWS, and received the SPX data for today and the previous 4 sessions. Note that today’s data is incomplete – the OHLC data only represents a short period after 8:30am Chicago time. But we only need today’s open price (for figuring out today’s ATM strike).

It then calculated the last four days’ straddle-implied expected moves and the actual SPX moves, and then took the averages to get the trade signal.

Next, it got today’s strike from today’s SPX open price.

It then asked TWS for a current snapshot of top of book prices for the 0DTE ATM call and put, and used these prices to submit limit orders.

In this case, shortly after the orders were sent, they were filled. In practice, you would need to handle this more carefully, for example dealing with cases where the market ran away from you and your limit order was unfilled.


In this article, we saw a minimal implementation of a strategy that uses the recent SPX actual moves and straddle-implied moves to directionally trade 0DTE straddles.

The main hurdle was obtaining data for expired SPXW options, but luckily the very recent contracts are available on Yahoo Finance.

It would be nice to simulate this strategy, but it would require historical SPXW option opening and closing prices. Until then, I am considering trading this at very small size (using the minis) just to gain some insight into how it trades.

Please note that this is only a minimal example. In particular, I’ve not included any order handling logic, other than to submit limit orders and print their status to screen.

8 thoughts on “Trading 0DTE Options with the IBKR Native API”

  1. Hi Kris, thanks for sharing the idea and the process you handle it. For me, the difference between “straddle” expected SPX move and realized SPX move is surprisingly high, which I think is in favor of the idea that the strangles are indeed “mispriced” and there is an edge. On the other hand, the variance of the difference is also very high, and to both side (two days overpriced, 2 days underpriced), which I think indicates the noise in the strategy results will be also high. I am a quant beginner; I really appreciate you share your ideas and experience.

    • I suspect you’re on the money here, but it’s hard to say anything much at all since we only have one data point in this example. Probably the hardest thing to overcome will be costs, since the strategy trades two 0DTE options positions, its costs will be a high proportion of its returns.

  2. Hi Kris, I am not an Options expert but what is with GARCH forecasts compared to the implied volatility? When the IV is higher than the forecast = short.
    If the IV is lower than the forecast = long.
    What do you think about that?

    • That approach depends entirely on your forecast of IV being better than the market’s. You can test it by analysing how well your model predicted volatility in the past vs the market.

  3. Hey Kris thanks for sharing this.

    Wouldn’t the straddle be on average overpriced (your trading signal would be most of the time negative)? People need to be rewarded for selling these options.

    Meaning most of the time you would be selling (naked) options. Ideally you would delta hedge this but the costs will be even higher…

    • Yeah I would expect it to be overprived on average. Personally I wouldn’t delta hedge, I’d just manage the variance through the position sizing. Will be too costly to delta hedge (assuming it even makes money, which I’m increasingly skeptical of…).

  4. Hi Kris, thanks for sharing the idea and the code.

    You mentioned the lack of data. If buying a straddle works – then it’s a good strategy. On the other hand, if we sell a straddle and don’t have a stop loss – once in a while (say once in 2-3 years), there could be some black swan event, which could offset any accumulated profits.

    So, I’d say it’s necessary to test this idea on a greater amount of historical data, which, as you mentioned, is not easy to obtain for options.


Leave a Comment