Insights
Pulling Optionable Tickers via API in Python
Alphanume Team · June 4, 2026
A point-in-time options universe in a DataFrame.
Every options backtest starts with the same question: which names actually had listed options on the date I'm simulating? If you answer it with today's optionable list, you silently include tickers that weren't tradeable then and exclude names that were delisted since — a survivorship trap specific to options that inflates every signal you test. This tutorial walks through pulling the Historical Optionable Tickers dataset via the optionable tickers api python developers need for point-in-time universe construction, loading the monthly snapshot into a DataFrame, and filtering it down to a clean tradeable set for any past date.
The setup
You need requests and pandas. Keep your API key in an environment variable — not in source code. All Alphanume endpoints share one request pattern: a GET against the base URL with query parameters, with the payload returned under a data key alongside a count field.
import os
import requests
import pandas as pd
BASE_URL = "https://api.alphanume.com/v1"
API_KEY = os.environ["ALPHANUME_API_KEY"]
def get_data(endpoint, **params):
params["api_key"] = API_KEY
resp = requests.get(f"{BASE_URL}/{endpoint}", params=params, timeout=30)
resp.raise_for_status()
return resp.json()["data"]
Calling raise_for_status() ensures a 401 or 429 blows up immediately rather than silently producing a DataFrame of error JSON. Unwrapping ["data"] strips the envelope; the companion count field lets you double-check that you got everything you expected.
Pulling the optionable-tickers snapshot
The endpoint returns a monthly point-in-time snapshot of U.S. equities that had listed options. Pass a date and you get back the universe as it was known that month — not adjusted for anything that happened later.
def optionable_snapshot(date):
rows = get_data("optionable-tickers", date=date)
df = pd.DataFrame(rows)
df["snapshot_date"] = pd.to_datetime(df["snapshot_date"])
return df
snap = optionable_snapshot("2023-01-31")
print(snap.shape)
print(snap.columns.tolist())
print(snap.head())
The snapshot date is month-end. Pass the last calendar day of any month and you get the universe that was current during that period. The response includes fields for the ticker, a flag confirming listed options, and expiration-density information — including whether weekly expirations existed for that name on that date.
Filtering to a tradeable options universe
Raw optionability isn't enough for most strategies. You typically want names with sufficient expiration choice — at minimum weeklies, or a density threshold that guarantees you can trade the term structure you need. Filter after loading, not before, so you keep the full snapshot for audit purposes.
def build_universe(date, require_weeklies=True, min_expiry_density=None):
df = optionable_snapshot(date)
# Keep only confirmed optionable names
universe = df[df["has_options"] == True].copy()
if require_weeklies:
universe = universe[universe["has_weekly_expirations"] == True]
if min_expiry_density is not None:
universe = universe[
universe["expiration_density"] >= min_expiry_density
]
return universe.reset_index(drop=True)
universe_jan23 = build_universe("2023-01-31", require_weeklies=True)
print(f"{len(universe_jan23)} names with weeklies as of 2023-01-31")
Separating the fetch from the filter keeps get_data reusable and makes it straightforward to tighten or loosen the criteria without hitting the API again.
The survivorship trap in options backtesting
Using today's optionable list for a 2022 backtest is subtly wrong in two directions. Names that have since been delisted or lost their option listing are absent from today's snapshot, so their (often poor) returns never appear in your test. Names that weren't optionable in 2022 but are today get included, letting future information about their growth inflate results. This is the options-specific flavor of survivorship bias — a clean explanation of the broader problem lives in the post on which stocks have options and why the answer changes over time. The only fix is to query the snapshot for the exact date you're simulating, every time.
Intersecting with your own universe
The snapshot gives you everything that was optionable. Most strategies need the subset that also passes your own filters — a liquidity screen, a sector constraint, or a list of names your portfolio already tracks. An inner join on ticker handles this cleanly.
def intersect_universe(date, your_tickers, require_weeklies=True):
options_universe = build_universe(date, require_weeklies=require_weeklies)
your_df = pd.DataFrame({"ticker": list(your_tickers)})
combined = your_df.merge(
options_universe[["ticker", "expiration_density"]],
on="ticker",
how="inner",
)
return combined.sort_values("ticker").reset_index(drop=True)
my_names = ["AAPL", "MSFT", "TSLA", "SPY", "XYZ_FAKE"]
tradeable = intersect_universe("2023-01-31", my_names)
print(tradeable)
The how="inner" merge keeps only the names that appear in both sets. XYZ_FAKE drops out because it was never in the snapshot; any name that was optionable then but isn't in your watch list also drops out. The result is the intersection that was actually tradeable on that date.
Building the universe for a past month — a worked example
Here is the full workflow for a single historical rebalance date. Query the snapshot, apply filters, intersect with a watch list, and inspect what you're left with. This is the pattern you'd run at the start of each simulated period in a backtest.
REBALANCE_DATE = "2022-06-30"
watch_list = [
"AAPL", "MSFT", "AMZN", "GOOGL", "META",
"TSLA", "NVDA", "JPM", "GS", "SPY", "QQQ",
]
# Step 1: pull the point-in-time snapshot
snap = optionable_snapshot(REBALANCE_DATE)
print(f"Total optionable names on {REBALANCE_DATE}: {len(snap)}")
# Step 2: apply expiration filters
filtered = build_universe(
REBALANCE_DATE,
require_weeklies=True,
min_expiry_density=None,
)
print(f"Names with weeklies: {len(filtered)}")
# Step 3: intersect with watch list
final = intersect_universe(
REBALANCE_DATE,
watch_list,
require_weeklies=True,
)
print(f"Tradeable names from watch list: {len(final)}")
print(final[["ticker", "expiration_density"]])
Run this for each rebalance month in your simulation and the universe at every step reflects what was genuinely available — no contamination from names that gained option listings later. For a deeper look at how to query and filter this data interactively, the guide on how to find optionable stocks via API covers additional field-level filtering patterns.
The discipline of using the snapshot for that date
The entire value of a point-in-time dataset evaporates the moment you mix dates. A few habits prevent that:
- Always pass the simulation date, never today. Hard-code the date variable at the top of your backtest loop; never let it default to the current date.
- Cache by date, not by run. If you cache API responses, key the cache on the snapshot date so a re-run tomorrow uses the same historical data.
- Audit the count. Log
resp.json()["count"]alongside the date each time you pull. A sudden drop tells you the filter is too tight or a date is out of range before you've built any strategy on top of it. - Keep the raw snapshot separate from your filtered universe. Save both. If a bug in your filter logic surfaces later, you can re-filter without re-querying.
Options universes change faster than most traders expect — names gain weeklies, lose them, and sometimes lose option listings entirely after low-volume stretches. Building a backtest on a static snapshot of today's universe treats that history as fixed, which it wasn't. Querying the monthly snapshot for each simulation date is the only way to stay honest.