How I Pull Live SPX Options Chains From Schwab’s API
Every weekday morning, a small script on my home server snapshots the same-day SPX options chain and saves it, so I can compare what the market expected to happen with what actually did.
Ingredients
- Schwab Market Data API — quotes and full options chains, including greeks and implied volatility (free with an API-enabled account)
- A token that stays alive — covered in How I Self-Healed Schwab’s 7-Day OAuth Expiry (May 22) (prerequisite)
- Python + requests — three API calls per run, nothing fancy (free)
- SQLite — a single-file database; no server to run (free)
- Headless Linux server + cron — the always-on box from earlier posts, running the script on a schedule (already set up)
- A small options-math library — Black-Scholes, an implied-vol solver, and realized-vol estimators, used for the analysis step (free)
- Claude Code — terminal AI for writing the collector and chasing down the bugs ($200/yr)
This is a data-collection project: a script that reads public market prices and writes them to a file. It places no orders and knows nothing about any account. I’m writing it up because the plumbing (endpoints, response shapes, the ways it fails) is the part that’s hard to find written down. This is not investment advice. Nothing here is a recommendation to buy, sell or trade anything, and the numbers below describe a small sample of past data, not what will happen next. If you’re making real decisions with real money, talk to a licensed professional.
The Question: Does the Market Overprice the Day?
Every option price has a forecast buried inside it. If you know an option’s price, you can work backward to the amount of movement the market is pricing in. That number is called implied volatility, or IV. After the day ends, you can measure how much the price actually moved. That’s realized volatility, or RV.
The gap between the two is one of the most-studied things in finance, and I wanted to see it with my own data rather than take it on faith. Same-day SPX options (called “0DTE,” for zero days to expiration) are a clean place to look: they expire at 4pm, so by the end of the afternoon you know exactly how the forecast turned out.
So the plan: snapshot the chain through the first two hours of trading (9:30 to 11:30am Eastern), then record what actually happened after the close, and compare.
Why only the morning? Because the question is what the market expected the day to look like, and that forecast is cleanest early, before most of the day’s move has happened. By the afternoon, same-day options have so little time left that their prices shrink to pennies, and a one-cent change in price can swing the implied-vol reading wildly. Two hours of snapshots is enough to see how the forecast settles after the open, without collecting hours of increasingly noisy numbers.
What the Data Shows: Implied vs Realized
After the close, a second scheduled run fills in the day: the close, the day’s high and low, and two versions of realized volatility.
- Close-to-close: how far SPX moved from yesterday’s close to today’s, scaled to an annual number so it’s comparable to IV. It’s simple, but it’s one data point per day and very noisy. A day that swings wildly and ends flat looks like a calm day.
- Range-based: uses the day’s high and low instead. It catches the intraday swings close-to-close misses. The standard version is called Parkinson, and there are refinements (Garman-Klass, Yang-Zhang) that also use the open and close.
A separate analysis script then goes further. Instead of trusting Schwab’s IV number, it solves for IV itself from the at-the-money option’s midpoint price, using a proper Black-Scholes solver, and computes realized vol with all three range-based estimators over the trailing 20 days. Two independent readings of the same thing are a good way to catch a bug in either one.
Across every day with a complete end-of-day record, near-the-money implied vol averaged 15.7% over the morning, close-to-close realized averaged 9.3%, and range-based realized averaged 7.0%. Implied came in above close-to-close realized on roughly six days out of seven. The market priced in more movement than showed up, which matches decades of published research. What I find more interesting is how much that gap moves around day to day, and how much the answer depends on which realized-vol measure you pick. A couple of months is a start, not a conclusion.
Subtract one from the other and the gap is easier to see. On the average day, implied vol came in 6.4 percentage points above close-to-close realized.
🔧 Developer section: options-math traps
- Time to expiry goes to zero. A same-day option at 3:59pm has almost no time left, and Black-Scholes breaks down as time approaches zero. My solver refuses to run when time left is zero or negative rather than returning a nonsense number, and the caller skips that point
- Vendor greeks and your greeks may use different assumptions. Schwab’s chain greeks appear to be computed with interest rates and dividends set to about zero. If you solve with real rates, your delta will come out a few hundredths higher than theirs. Neither is wrong; just never mix the two in one calculation
- Use the midpoint, and watch the spread. Near the money, the bid-ask spread on these options averaged about $0.16, or 1.5% of the midpoint. That’s tight enough that the mid is a fair price to solve from. Further out, spreads widen and the mid gets less trustworthy
- Don’t hand-roll the math. I use a small, tested options library for the pricing, the IV solver and the RV estimators. An IV solver that quietly returns its search bound when it fails to converge will give you plausible-looking garbage for weeks
That’s the finding. The rest of this post is how the data gets collected, and the ways the collector broke along the way.
The Three API Calls
The whole collector is three requests to Schwab’s Market Data API, each with the access token in the header:
- An SPX quote (
/marketdata/v1/quotes?symbols=$SPX) — the current price, today’s open, high and low, and yesterday’s close. Index symbols take a$prefix, which you’ll need to URL-encode as%24. - A VIX quote (same endpoint,
$VIX) — the market’s own 30-day volatility gauge, saved alongside for context. - The options chain (
/marketdata/v1/chains) — the big one. Every strike near the current price, with bid, ask, last trade, volume, open interest, implied volatility and the greeks.
The chain request is where the choices live. Here’s what mine asks for:
Pinning fromDate and toDate to today keeps the response small. Without them you get every expiration Schwab lists, which for SPX is a lot of JSON on every run.
One design choice worth explaining: I only pull one side of the chain per day, calls or puts. The reason is that near the current price, a call and a put at the same strike imply almost exactly the same volatility. That’s a pricing relationship called put-call parity, and it means the second side would mostly be a duplicate of the first. So the script picks one: if SPX opens above yesterday’s close it logs calls, and if it opens below, puts. That halves the data without losing the implied-vol reading, which is the number this whole project is about. It’s a rule for what to record, not a rule for what to do.
🔧 Developer section: reading the chain response
- Contracts live under
callExpDateMaporputExpDateMap, keyed first by expiration, then by strike. Each strike maps to a list of contracts, usually one long - Expiration keys look like
2026-09-29:0. The number after the colon is days to expiration, so a same-day key ends in:0. That suffix is the most reliable way I found to pick the 0DTE bucket out of the map volatilityis implied vol times 100. A value of17.3means 17.3%, not 1,730%. Divide before storing, or every later calculation is off by a factor of 100- The chain comes back with
status: "SUCCESS"when it worked. Check it; a 200 response with a different status is still a failed read - Side labels come back uppercase (
CALL/PUT). My analysis code expected lowercase and silently matched nothing until I normalized it - Missing numbers come back as
nullor0. Coerce withfloat(x or 0)so one blank field doesn’t crash the whole snapshot
Storing It: Three Tables in One File
Each run writes one row for the moment (SPX price, VIX, which side it logged, minutes since the open) and thirty rows for the strikes (bid, ask, midpoint, spread, IV, delta, gamma, theta, vega, volume, open interest, and how far each strike is from the current price). A third table keeps one row per day, which the end-of-day run fills in with the close and the realized-vol numbers.
After a couple of months of trading mornings, the whole thing still fits in a database file smaller than most phone photos. SQLite is plenty here. It’s one file, it needs no server, and a couple dozen writes a morning is nowhere near straining it.
A normal morning: one line per step, one summary line per snapshot. The end-of-day line is the payoff: how much movement was priced in vs how much showed up.
The Shape of a Morning
Saving the chain through the morning, instead of once a day, shows something a single snapshot never could: near-the-money implied vol drifts down as the morning goes on. Averaged across every day collected so far, it starts around 17% right after the open and settles near 15% by late morning.
What Breaks
The code is short. Almost everything I learned came from the ways it failed, so here they are in the order they cost me.
1. The time-zone bug that collected nothing
The script checks the clock and exits unless it’s between 9:30 and 11:30am Eastern. That’s a sensible guard. The problem was the schedule that launched it: I wrote the cron entry as “run from 9 to 11,” thinking in market hours. But the server runs on Pacific time. So cron fired from 9 to 11am Pacific, which is 12 to 2pm Eastern, and every single run looked at the clock, saw it was outside the window, and quietly exited.
No errors. No crashes. A clean log, and no data. The fix was one line (schedule it for 6 to 8am Pacific), plus a comment in the code explaining why the hours look wrong.
Each piece was right on its own: the script gated on Eastern time, and the schedule was written in market hours. Together they never overlapped. If a job has both a schedule and a time check inside it, make sure they agree on the time zone. And on day one, check that rows are actually showing up, not just that the log is clean.
2. Market holidays
The script knows about weekends. It doesn’t know about holidays. On Labor Day it ran on its normal schedule all day, found no same-day expiration because the market was closed, and filled the log with empty-chain warnings. Harmless, because it doesn’t write anything when the chain is empty. But the empty-chain path is the only reason it didn’t write garbage, and that’s luck, not design.
The fix is to ask whether the market is open before doing anything else. There are two good ways to do that:
- Ask Schwab. The same Market Data API has a market-hours endpoint (
/marketdata/v1/markets?markets=option) that says whether the options market is open today and what its session hours are. One extra call at the top of the script, and it exits early on a closed day. It also knows about half days, like the day after Thanksgiving, when the market closes early. - Use a calendar library. If you’d rather not spend an API call, a Python package like
exchange_calendarsships the official exchange holiday schedule, so the check works even if Schwab is down. The trade-off is that you have to keep the package updated as new years’ holidays are published.
I’d use Schwab’s endpoint as the main check and the library as a backup. Either way, the rule is the same: a closed market should be a clean, logged “skipping today,” not a day of warnings.
3. Random 400s on a request that worked on the last run
Every so often, the chain endpoint returns 400 Bad Request for a stretch of the morning, even though nothing about the request has changed and the quote endpoint is answering fine. Then it clears up on its own.
A 400 is supposed to mean your request is malformed, so this one is easy to misread. What matters is that the script treats a failed chain as “skip this snapshot” rather than “crash” or “write a partial row.” A day with 18 of 24 snapshots is still a usable day. A day with 18 good rows and six half-written ones is not.
4. Timeouts, and the 7-day wall
A handful of runs hit a read timeout (10 seconds for quotes, 15 for the chain). Same treatment: log it, skip it, try again on the next run. And if the token ever lapses, every call fails at step one, which is exactly why the token post exists: a collector like this is only as reliable as the token underneath it.
5. The “empty database”
A Python gotcha worth knowing: the SQLite library creates a new empty file if the one you ask for doesn’t exist. Check the wrong path and you’ll find an “empty database” that you just made by looking for it.
🔧 Developer section: failure handling, in one list
- Weekend or outside 9:30–11:30 ET → exit quietly, no API calls at all
- No token → log and exit; every downstream call would fail anyway
- SPX quote fails or price is 0 → exit; without the underlying there’s nothing to anchor the chain to
- VIX quote fails → log a warning and continue with a blank; it’s context, not required
- Chain fails, times out, or comes back empty → skip the snapshot, write nothing
- Opening the database to check it → use read-only mode (
sqlite3.connect("file:PATH?mode=ro", uri=True)) so a wrong path raises an error instead of creating an empty file
What went fast
- The collector itself — three requests, a loop over strikes, three inserts. The first working version took one session.
- The schema — one table per grain (moment, strike, day) turned out to be the right call. Every question I’ve asked since has been a simple join.
- Reusing the token plumbing — the hard auth work was already done. This project just called the shared helper.
What needed patience
- Time zones — the fix was one line, but it’s the kind of bug a clean log won’t show you. A job that exits cleanly looks the same as a job that works, so count the rows.
- Reading Schwab’s response format — the expiration-key suffix, the IV-times-100 convention and the uppercase labels are each small. Each one cost a round of “why is this number wrong?”
- Being honest about realized vol — the first version used close-to-close only, and it made the gap look cleaner than it is. Adding the range-based estimators made the story messier and more accurate.
The token post was about keeping a door open. This one is about what walks through it: a few thousand numbers a day, saved on a schedule, that slowly turn into an answer to a question I actually care about: the market prices in more movement than it delivers, most days, and now I can see by how much.