OptionSpace.ai

Starter notebooks

Learn by running real queries.

Four short Jupyter notebooks that walk through the package with small, fast inputs. Download one, open it in Jupyter and run it top to bottom with your own key. The code from each notebook is shown on this page.

Before you start

Complete installation and login first. Then, in the terminal (PowerShell on Windows), inside your research folder:

uv add notebook
uv run jupyter notebook

Jupyter opens in your browser. Save the downloaded notebooks into your research folder and open them from there. The notebooks read the key you saved with uv run optionspace login; never paste your key into a notebook cell, because notebooks get shared and a key in a cell travels with them.

The examples use past sessions. If a date falls outside your access, change the inputs near the top of the notebook to a session your plan covers.

  1. Quickstart: Sign in, check your access, list the symbols, plot one fitted smile and load one option chain as a DataFrame. About 5 requests.
  2. Chains and surfaces on a past date: For one stock on one past session: the snapshots and expiries available, the full chain, a strike-by-expiry volatility grid, the constant-maturity tenors and the ATM term structure. About 35 requests (the term structure makes one request per listed expiry).
  3. Intraday ATM volatility history: ATM volatility through one session for one expiry, then three weeks of 30-day ATM volatility and skew at every snapshot your plan allows, charted without bridging gaps. About 4 requests.
  4. Earnings research: For one stock: ATM volatility with and without its earnings, the move priced before each recent report against the move that followed, and a simple earnings put-spread backtest. 3 research calls.

Usage is counted against your daily allowance.

1. Quickstart

Sign in, check your access, list the symbols, plot one fitted smile and load one option chain as a DataFrame. About 5 requests.

Download 01-quickstart.ipynb

Your first five minutes with OptionSpace from Python: connect with your saved key, see what your plan includes, list the symbols, plot one fitted volatility smile and load one option chain as a pandas DataFrame.

Before you start. Follow Getting started once (Windows, macOS or Linux). Then, in a terminal, inside that research folder:

uv add notebook
uv run jupyter notebook

uv run optionspace login (from Getting started) asks for your API key once (the input is hidden) and keeps it in your computer's credential store. The examples use SPY on 10 April 2025; change the inputs in step 4 to any symbol and past session your plan covers.

Usage. Your daily allowance is counted in credits. client.prices() lists what each call costs, a research call says what it cost in result['credits'], and client.entitlements() shows what you have used today.

1. Connect

osp.Client() uses the key you saved with uv run optionspace login. If you prefer an environment variable, set OPTIONSPACE_API_KEY in the terminal you start Jupyter from. Never paste your key into a notebook cell: notebooks get shared, and a key in a cell travels with them.

import optionspace as osp

client = osp.Client()
print("optionspace", osp.__version__, "| key from:", client.credential_source)

2. Check your access

entitlements() shows your plan, your daily limits and what you have used today.

access = client.entitlements()
for field in ("tier", "status", "limits", "usage"):
    print(f"{field:>8}: {access.get(field)}")

3. List the symbols

One row per symbol, with the first and last session it covers.

symbols = client.symbols()
print(len(symbols), "symbols")
symbols.head(10)

4. Fetch one fitted smile

Pick a symbol, a past session and a listed expiry. expirations() lists the expiries that were trading that day, so you never have to guess one. snapshot="EOD" asks for the last snapshot at or before the close; the time it resolved to comes back with the data.

SYMBOL, DATE, EXPIRY = "SPY", "2025-04-10", "2025-04-17"

client.expirations(SYMBOL, DATE).head(8)
smile = client.smile(SYMBOL, DATE, expiration=EXPIRY, snapshot="EOD")

summary = {k: smile.attrs.get(k) for k in ("symbol", "data_date", "snapshot_time", "expiration",
                                         "dte", "forward", "atm_vol")}
print(summary)
columns = [c for c in ("strike", "put_call", "fitted_vol", "iv_bid", "iv_ask", "delta",
                       "is_wing_extrapolated") if c in smile.columns]
smile[columns].head(10)

Volatilities are decimals: 0.20 means 20%. is_wing_extrapolated is True where a strike lies outside the range the fit was made on, so the value there is an extension of the curve rather than a fit to quotes.

5. Plot it

charts.smile_chart draws exactly what the API returned: the fitted curve, the bid and ask volatilities at each strike, and where the curve is extended beyond the fitted range. Redrawing a chart makes no new request.

from optionspace import charts

fig = charts.smile_chart(smile)
fig.show()

To keep a copy you can open later in any browser:

fig.write_html("spy-smile.html", include_plotlyjs=True)

6. Load one option chain as a DataFrame

chain() returns every fitted option at one snapshot. Filtering to one expiry keeps this first pull small; drop expiration= for the whole chain (notebook 2 does that).

chain = client.chain(SYMBOL, DATE, "EOD", expiration=EXPIRY)
print(chain.shape, "| snapshot", chain.attrs.get("snapshot_time"))
chain.head()
calls = chain[chain["put_call"] == "call"]
wanted = ["strike", "fitted_vol", "fitted_price", "delta", "gamma", "vega", "theta"]
calls[[c for c in wanted if c in calls.columns]].head(10)

Next

Every field is described in the field reference at https://optionspace.ai/docs-api.html#fields. Questions or problems: [email protected] (never include your API key).

2. Chains and surfaces on a past date

For one stock on one past session: the snapshots and expiries available, the full chain, a strike-by-expiry volatility grid, the constant-maturity tenors and the ATM term structure. About 35 requests (the term structure makes one request per listed expiry).

Download 02-chains-and-surfaces.ipynb

Everything listed for one stock on one past session: which snapshots and expiries exist, the full option chain, a volatility grid by strike and expiry, the standard constant-maturity tenors, and the ATM term structure.

Install and log in as in notebook 1. The example is AAPL on 10 April 2025; change the inputs below to any single-stock symbol and past session your plan covers.

Usage. Your daily allowance is counted in credits. client.prices() lists what each call costs, a research call says what it cost in result['credits'], and client.entitlements() shows what you have used today.

1. Connect

osp.Client() uses the key you saved with uv run optionspace login. If you prefer an environment variable, set OPTIONSPACE_API_KEY in the terminal you start Jupyter from. Never paste your key into a notebook cell: notebooks get shared, and a key in a cell travels with them.

import optionspace as osp

client = osp.Client()
print("optionspace", osp.__version__, "| key from:", client.credential_source)
SYMBOL, DATE = "AAPL", "2025-04-10"

2. What exists that day

snapshots() lists the times your plan can retrieve for the session, and which one "EOD" resolves to. expirations() lists every expiry trading that day, with its calendar days to expiry.

snaps = client.snapshots(SYMBOL, DATE)
print(len(snaps), "snapshots from", snaps["snapshot_time"].iloc[0], "to", snaps["snapshot_time"].iloc[-1],
      "| EOD =", snaps.attrs.get("eod"), "| your granularity:", snaps.attrs.get("granularity"))

expiries = client.expirations(SYMBOL, DATE)
print(len(expiries), "listed expiries")
expiries

3. The full chain at the close

One request returns every fitted option across all expiries at one snapshot. A chain too large for one response raises ResponseTooLarge instead of being cut short; narrow it with expiration=, strike_min=/strike_max= or put_call=.

chain = client.chain(SYMBOL, DATE, "EOD")
print(f"{len(chain):,} options at {chain.attrs.get('snapshot_time')} ET")
chain.groupby("expiration").size().rename("options").to_frame().T

4. A volatility grid: strikes by expiry

Out-of-the-money options only (puts below the forward, calls above), fitted volatility in percent, for strikes within 15% of the forward.

otm = chain[((chain["put_call"] == "put") & (chain["strike"] < chain["forward"]))
            | ((chain["put_call"] == "call") & (chain["strike"] >= chain["forward"]))]
near = otm[(otm["strike"] / otm["forward"] - 1).abs() <= 0.15]
grid = near.pivot_table(index="strike", columns="expiration", values="fitted_vol", aggfunc="first") * 100
grid.round(1)

5. The same expiry at three times of day

snapshots= pulls a set of times, one request each. Before it starts, the client checks the pull fits what is left of your daily allowance, and it records the cost in .attrs["budget"].

first_expiry = expiries["expiration"].iloc[1]
day = client.chain(SYMBOL, DATE, snapshots=["10:02", "12:32", "15:57"], expiration=first_expiry)
print(day.attrs["budget"])
atm_by_time = (day.assign(distance=(day["strike"] - day["forward"]).abs())
                  .sort_values("distance").groupby("snapshot_time").first())
atm_by_time[["strike", "forward", "fitted_vol"]]

6. The constant-maturity tenors

tenor_history() reads one standard maturity (30, 60, 90, 180 or 360 days) through time. Asking for one session at "EOD" for each tenor gives the day's tenor grid: at-the-forward volatility and 25-delta skew by maturity.

import pandas as pd

tenors = pd.concat([client.tenor_history(SYMBOL, t, DATE, DATE, snapshot="EOD",
                                         fields=["atf_iv", "skew_25d"])
                    for t in (30, 60, 90, 180, 360)], ignore_index=True)
tenors[["tenor_days", "snapshot_time", "atf_iv", "skew_25d"]]

A tenor value is empty when no listed expiries span that maturity on that day. That is expected, not missing data.

7. The ATM term structure

term_structure() reads the at-the-money volatility of every listed expiry. It makes one request per expiry (about 20 for AAPL), and checks they fit your remaining allowance before it starts.

from optionspace import charts

term = client.term_structure(SYMBOL, DATE)
print(term.attrs["budget"])
term[["expiration", "dte", "atm_vol", "forward"]]
charts.term_comparison_chart([term]).show()

8. The one-call summary

surface() returns a one-request, end-of-day summary per listed expiry. It is a separate, older summary: use the term structure above when you need the listed-expiry values themselves.

surface = client.surface(SYMBOL, DATE)
surface.head()

3. Intraday ATM volatility history

ATM volatility through one session for one expiry, then three weeks of 30-day ATM volatility and skew at every snapshot your plan allows, charted without bridging gaps. About 4 requests.

Download 03-intraday-atm-history.ipynb

How at-the-money volatility moved through the day: first one expiry through one session, then three weeks of the 30-day constant-maturity value at every snapshot your plan allows (every minute on Pro). The example is SPY from 24 March to 11 April 2025, which includes the April 2025 tariff selloff.

Install and log in as in notebook 1.

Usage. Your daily allowance is counted in credits. client.prices() lists what each call costs, a research call says what it cost in result['credits'], and client.entitlements() shows what you have used today.

1. Connect

osp.Client() uses the key you saved with uv run optionspace login. If you prefer an environment variable, set OPTIONSPACE_API_KEY in the terminal you start Jupyter from. Never paste your key into a notebook cell: notebooks get shared, and a key in a cell travels with them.

import optionspace as osp

client = osp.Client()
print("optionspace", osp.__version__, "| key from:", client.credential_source)
SYMBOL = "SPY"
START, END = "2025-03-24", "2025-04-11"
DAY, EXPIRY = "2025-04-09", "2025-04-17"

GRAIN = client.snapshots(SYMBOL, DAY).attrs.get("granularity")
STEP = {"1m": 1, "5m": 5, "30m": 30}.get(GRAIN)
print("Your plan's finest grain:", GRAIN)

2. One expiry through one session

atm_intraday() returns the ATM volatility of one listed expiry at every snapshot of a session. 9 April 2025 is the day the tariff pause was announced in the early afternoon.

from optionspace import charts

atm = client.atm_intraday(SYMBOL, DAY, expiration=EXPIRY)
print(len(atm), "snapshots")
charts.atm_chart(atm).show()

3. Three weeks at every snapshot

tenor_history() with no snapshot returns every snapshot your plan allows, for one constant maturity. One request covers the whole range; a range too large for your grain is refused with the estimated row count, never cut short.

hist = client.tenor_history(SYMBOL, 30, START, END, fields=["atf_iv", "skew_25d"])
print(f"{len(hist):,} rows over {hist['data_date'].nunique()} sessions")
hist.groupby("data_date").size().rename("snapshots").to_frame().T

4. Chart it on a trading-time axis

Nights and weekends are removed so each session sits next to the last. Where a snapshot is missing, the line breaks instead of drawing across the hole.

import pandas as pd
import plotly.graph_objects as go


def trading_axis(df, column, step):
    sessions = sorted(df["data_date"].unique())
    xs, ys = [], []
    for i, session in enumerate(sessions):
        rows = df[df["data_date"] == session].sort_values("snapshot_time")
        previous = None
        for t, value in zip(rows["snapshot_time"], rows[column]):
            minute = int(t[:2]) * 60 + int(t[3:]) - 9 * 60 - 30
            if previous is not None and step and minute - previous > step:
                xs.append(None)
                ys.append(None)
            xs.append(i * 400 + minute)
            ys.append(None if pd.isna(value) else value * 100)
            previous = minute
        xs.append(None)
        ys.append(None)
    return xs, ys, sessions


x, y, sessions = trading_axis(hist, "atf_iv", STEP)
fig = go.Figure(go.Scatter(x=x, y=y, mode="lines", line=dict(width=1), name="30-day ATM vol"))
fig.update_layout(title=f"{SYMBOL} 30-day ATM volatility, every {GRAIN} snapshot",
                  yaxis_title="Volatility (%)", template="simple_white", height=420)
fig.update_xaxes(tickvals=[i * 400 for i in range(len(sessions))][::3], ticktext=sessions[::3])
fig.show()
x, y, sessions = trading_axis(hist, "skew_25d", STEP)
fig = go.Figure(go.Scatter(x=x, y=y, mode="lines", line=dict(width=1), name="25-delta skew"))
fig.update_layout(title=f"{SYMBOL} 30-day 25-delta skew (put minus call, vol points)",
                  yaxis_title="Vol points", template="simple_white", height=420)
fig.update_xaxes(tickvals=[i * 400 for i in range(len(sessions))][::3], ticktext=sessions[::3])
fig.show()

5. Compare with the closing values

snapshot="EOD" gives one value per session, the last snapshot at or before the close. charts.history_chart draws it with gaps left as gaps.

closes = client.tenor_history(SYMBOL, 30, START, END, snapshot="EOD", fields=["atf_iv", "skew_25d"])
charts.history_chart([closes]).show()

6. The busiest minutes

The ten largest one-snapshot moves in 30-day ATM volatility over the window, in vol points.

moves = hist.sort_values(["data_date", "snapshot_time"]).copy()
moves["change"] = moves.groupby("data_date")["atf_iv"].diff() * 100
moves.reindex(moves["change"].abs().sort_values(ascending=False).index)[
    ["data_date", "snapshot_time", "atf_iv", "change"]].head(10)

4. Earnings research

For one stock: ATM volatility with and without its earnings, the move priced before each recent report against the move that followed, and a simple earnings put-spread backtest. 3 research calls.

Download 04-earnings-research.ipynb

Three research calls, each answering a whole question in one request:

  1. Clean ATM history: 30-day ATM volatility with and without the next scheduled earnings report.
  2. Earnings history: the move the options priced before each recent report, against the move that followed, and how the priced move built up.
  3. Earnings backtest: a simple put spread sold before each report and closed after it, with fills, fees and the position's daily risk.

The example is NVDA as of 16 September 2026. Install and log in as in notebook 1.

Research calls are slower. A lookup takes well under a second; a research call can take several seconds to a minute. Ask what one can cost with estimate=True before running it, and its result says what it cost. They run one at a time per account: starting a second while one is running is refused with a short wait time.

1. Connect

osp.Client() uses the key you saved with uv run optionspace login. If you prefer an environment variable, set OPTIONSPACE_API_KEY in the terminal you start Jupyter from. Never paste your key into a notebook cell: notebooks get shared, and a key in a cell travels with them.

import optionspace as osp

client = osp.Client()
print("optionspace", osp.__version__, "| key from:", client.credential_source)
import time

import pandas as pd
from optionspace import charts

SYMBOL, AS_OF = "NVDA", "2026-09-16"


def timed(call, *args, **kwargs):
    started = time.perf_counter()
    result = call(*args, **kwargs)
    print(f"{call.__name__}: {time.perf_counter() - started:.1f} s")
    return result

2. ATM volatility with and without earnings

clean_atm_history() returns two series at one constant maturity: the value the market quoted (dirty_iv) and the value with the next scheduled earnings report removed (clean_iv). Where the removal cannot be vouched for, the clean value repeats the quoted one and status says why.

clean = timed(client.clean_atm_history, SYMBOL, "2026-01-02", AS_OF, tenor_days=30)
rows = pd.DataFrame(clean["rows"])
print("earnings in the window:", [e.get("date") for e in clean.get("earnings", [])])
rows[["data_date", "snapshot_time", "dirty_iv", "clean_iv", "clean_iv_se", "status"]].tail(10)
charts.clean_atm_chart(clean).show()

Read the notes that come with every research result: they say what the numbers are and are not.

for note in clean.get("notes") or []:
    print("-", note)

3. Priced against realized, report by report

earnings_history() returns the most recent reports before the as-of date: implied_move is the average size of the move the options priced at the last close before the report (a fraction of the stock price, about 0.80 times the event's volatility), and actual_move is the size of the move that followed (actual_return keeps its sign).

history = timed(client.earnings_history, SYMBOL, AS_OF, count=8)
events = pd.DataFrame(history["events"])
events[["earnings_date", "timing", "implied_move", "actual_move", "actual_return", "status"]]
charts.earnings_comparison_chart(history).show()
charts.earnings_runup_chart(history).show()

4. A simple earnings backtest

earnings_backtest() sells a put spread (short near 25 delta, long near 10 delta, in the first standard monthly expiry after the report) a set number of sessions before each report and closes it after. Here: four reports, entry five sessions before, exit one session after, fills at the bid and ask, and $0.65 per contract per fill. The rules come back under assumptions.

backtest = timed(client.earnings_backtest, SYMBOL, AS_OF, count=4,
                 entry_offset=-5, exit_offset=1, fill_model="quote_side", fee_per_contract=0.65)
print(backtest["assumptions"])
print(backtest["summary"])
trades = pd.DataFrame(backtest["events"])
trades[[c for c in ("earnings_date", "status", "reason", "entry_date", "exit_date", "expiration",
                    "gross_pnl", "fees", "net_pnl") if c in trades.columns]]
charts.earnings_backtest_chart(backtest).show()
if backtest["summary"].get("traded_events"):
    charts.backtest_path_chart(backtest).show()

P&L is in US dollars for one spread. This is a historical simulation: it does not model assignment, financing, margin or market impact.

5. If a call is refused

Research calls run one at a time per account. If you start one while another is still running (in another notebook, say), the client raises QuotaExceeded with the reason and how many seconds to wait. This helper waits and tries again; it makes no request until you use it, for example with_retry(client.earnings_history, SYMBOL, AS_OF, count=8).

def with_retry(call, *args, attempts=3, **kwargs):
    for attempt in range(attempts):
        try:
            return call(*args, **kwargs)
        except osp.QuotaExceeded as refused:
            busy = (refused.reason or "").startswith("concurrent")
            if not busy or attempt == attempts - 1:
                raise
            wait = refused.retry_after_seconds or 5
            print(f"refused ({refused.reason}); trying again in {wait} s")
            time.sleep(wait)