Sitemap
Level Up Coding

Coding tutorials and news. The developer homepage gitconnected.com && skilled.dev && levelup.dev

Navigating Historical Options Data with the EODHD APIs

A walkthrough of how to access and utilize historical options data with the US Stock Options Data API by EODHD APIs with Python

10 min read10 hours ago

--

Press enter or click to view image in full size
Photo by rc.xyz NFT gallery on Unsplash

Disclaimer: I am an affiliate of EODHD APIs and may receive compensation if you purchase a product or subscription through links in this article. This content is for educational and informational purposes only and is not financial, investment, tax, or legal advice. Any stocks, options, strategies, or securities referenced are for illustrative purposes only and should not be considered recommendations. Investing and trading involve substantial risk, including loss of principal, and options can involve additional risks such as leverage, volatility, and time decay. Always conduct your own research and consider your financial situation, objectives, and risk tolerance before making investment decisions.

Introduction

Retrieving historical price data for an underlying stock is an easy enough task for most practitioners, whether you do a quick internet search, log into your brokerage account, or use a programmatic approach. On the other hand, historical options data becomes complex very quickly. An optionable underlying can have many expiration dates, each expiration can contain many listed strikes, each strike can have both calls and puts, and historical data adds another dimension across trading dates. Navigating this data and utilizing it is a challenge; however, in this article, I will walk you through how you can utilize the US Stock Options Data API by EODHD APIs to:

  • ingest historical options data.
  • understand the nuances surrounding it.
  • format and visualize it.

Import Libraries and API Key

If you haven't already, check out my previous article for a brief introduction to the US Stock Options Data API by EODHD APIs, where I cover not only the API but also how I use Python to access it.

# Imports
import requests
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
sns.set()
from google.colab import userdata

# Load EODHD API key from Google Colab Secrets
API_KEY = userdata.get("EODHDAPIKEY")

if not API_KEY:
raise ValueError(
"EODHDAPIKEY was not found in Google Colab Secrets."
)

print("EODHD API key loaded successfully.")

GET helper function for the EOD Endpoint

In the previous article, I utilized the contracts endpoint, which shows the latest EOD data for option contracts. The EOD endpoint lets us retrieve historical data for option contracts. Let’s look at the response to understand the data we will be working with, using AAPL as an example.

from pprint import pprint

def get_historical_option_data(api_key, params):
url = "https://eodhd.com/api/mp/unicornbay/options/eod"

request_params = params.copy()
request_params["api_token"] = api_key

response = requests.get(
url,
params=request_params,
timeout=30
)

response.raise_for_status()

return response.json()


params = {
"filter[underlying_symbol]": "AAPL",
"page[offset]": 0,
"page[limit]": 3
}

historical_options = get_historical_option_data(
API_KEY,
params
)

print("Top-level keys:")
print(historical_options.keys())

print("\nMeta:")
pprint(historical_options.get("meta"))

print("\nLinks:")
pprint(historical_options.get("links"))

print("\nFirst observation:")
pprint(historical_options["data"][0])
Image provided by the author
Image provided by the author
Image provided by the author (partial screenshot)
Image provided by the author

As you can see, the structure is similar to the contracts endpoint from my previous article. Notice the top-level “id” field. This combines the options contract symbol with the date of the historical EOD observation.

Retrieving history for a single contract

Now, let’s take a look at the history of the contract mentioned in the previous section.

params = {
"filter[contract]": "AAPL290119P00670000",
"page[offset]": 0,
"page[limit]": 1000
}

contract_history = get_historical_option_data(
API_KEY,
params
)

print("Number of observations returned:")
print(len(contract_history["data"]))

print("\nMeta:")
pprint(contract_history["meta"])

print("\nLinks:")
pprint(contract_history["links"])

print("\nFirst 3 observations:")
for row in contract_history["data"][:3]:
print(
row["id"],
"| exp_date:", row["attributes"]["exp_date"],
"| dte:", row["attributes"]["dte"],
"| tradetime:", row["attributes"]["tradetime"]
)

print("\nLast 3 observations:")
for row in contract_history["data"][-3:]:
print(
row["id"],
"| exp_date:", row["attributes"]["exp_date"],
"| dte:", row["attributes"]["dte"],
"| tradetime:", row["attributes"]["tradetime"]
)
Image provided by the author
Image provided by the author
Image provided by the author

At first glance, you may be surprised to find only 24 historical observations for this contract, especially since the EODHD APIs state that their US Stock Options data API coverage includes history going back to Q4 2023. This is where it helps to understand the anatomy of option chains. For any given underlying stock, a trading day will have a certain number of options by expiration date, and within each expiration date, a certain number of call and put options by strike price. The number of contracts is affected by:

  • exchange regulations
  • underlying price, which affects the strike price intervals
  • underlying price movement
  • expiration cycles
  • liquidity and/or popularity of the underlying

For this particular contract “AAPL290119P00670000”, this is a $670 put on AAPL that expires on 1/19/2029. As I write this article, it is a little more than 2 years away and $300 higher than AAPL's current underlying price. A long dated expiration date does not necessarily mean every single strike currently available was listed when the expiration first existed. The available strike universe can change over time as the underlying price fluctuates.

Get Jacob Ingle’s stories in your inbox

Join Medium for free to get updates from this writer.

One important nuance is that tradetime does not necessarily represent the date of the EOD observation itself; it may reflect the most recent trade or other market activity, so historical date filters should be interpreted accordingly.

In our sample, EODHD’s dte generally matched the calendar difference between the observation date and expiration date, but several Friday observations were one day lower. Because the exact calculation convention is not documented, I would avoid assuming that the API's dte is always identical to a simple date subtraction. If calendar DTE is important for a particular analysis, it can be calculated separately.

Image provided by the author

Exploring a contract with “significant” data

Let’s look at a contract with many more historical observations: the AAPL $250 Call that expired on 12/19/2025.

params = {
"filter[contract]": "AAPL251219C00250000",
"fields[options-eod]": (
"contract,"
"underlying_symbol,"
"exp_date,"
"type,"
"strike,"
"tradetime,"
"dte,"
"last,"
"bid,"
"ask,"
"midpoint,"
"volume,"
"open_interest,"
"volatility,"
"delta,"
"gamma,"
"theta,"
"vega,"
"rho,"
"moneyness"
),
"page[offset]": 0,
"page[limit]": 1000
}

long_history = get_historical_option_data(
API_KEY,
params
)

print("Number of observations returned:")
print(len(long_history["data"]))

print("\nLinks:")
pprint(long_history["links"])

print("\nFirst observation:")
pprint(long_history["data"][0])

print("\nLast observation:")
pprint(long_history["data"][-1])
Image provided by the author

The returned history spans October 3, 2023 through December 18, 2025, one day before the contract’s stated expiration date. One caveat: 10/02/2023 may not be the exact date this option started trading; rather, it may be the earliest date for which options history data is available.

As covered in my previous article, pagination, server-side filtering, and API-call limits are important considerations when working with the EODHD APIs. This becomes especially relevant for historical options data, where the number of observations can grow rapidly across contracts and trading dates.

One additional nuance worth mentioning is that historical field availability can vary. Although this particular contract has 565 historical observations dating back to October 2023, fields such as midpoint and moneyness are only populated beginning October 3, 2024. In other words, having historical observations doesn't necessarily mean every attribute is available throughout the entire period.

Visualizing Historical Options Data — Theta Decay

What good is historical data if you don’t visualize it? Specifically, in a way that can display trends and patterns. In this quick example, let’s explore Theta Decay among a few AAPL contracts.

For those that are new to options, Theta measures an option’s theoretical sensitivity to the passage of time, holding other factors constant. It is particularly relevant to options sellers, who generally benefit from time decay, although underlying price movements and changes in implied volatility can easily offset those benefits. There are many other factors that drive option prices but entire books have been written about that very subject.

A commonly cited options heuristic about theta decay is that it really starts to ramp up around the 45 to 21 days to expiration mark. Therefore, option sellers may prefer to enter into their trades around that window. Let’s pull some historical data for a few AAPL calls that have already expired and visualize theta by days to expiration and see if we can reveal any patterns that support this 45–21 dte heuristic.

For this example, we’ll retrieve historical observations for AAPL calls expiring September 18, 2026. To keep the analysis manageable, we’ll select five contracts that were closest to at-the-money around 45 DTE and had observations spanning approximately 65 to 10 DTE. We’ll then compare their theta magnitudes and visualize the average trend.

# ---------------------------------------------------------
# Explore theta decay across AAPL Sep. 18, 2026 call options
# ---------------------------------------------------------

EXPIRATION = "2026-09-18"

# Slightly wider than the 65–10 DTE window.
# We will filter precisely using the API's dte field afterward.
TRADETIME_FROM = "2026-07-13"
TRADETIME_TO = "2026-09-10"

# Broad enough to capture the relevant AAPL strikes
# while avoiding an unnecessarily large payload.
STRIKE_FROM = 250
STRIKE_TO = 380

PAGE_LIMIT = 1000
N_CONTRACTS = 5


# 1. Retrieve the scoped historical dataset
all_rows = []
offset = 0
requests_made = 0

while True:

params = {
"filter[underlying_symbol]": "AAPL",
"filter[type]": "call",
"filter[exp_date_from]": EXPIRATION,
"filter[exp_date_to]": EXPIRATION,
"filter[strike_from]": STRIKE_FROM,
"filter[strike_to]": STRIKE_TO,
"filter[tradetime_from]": TRADETIME_FROM,
"filter[tradetime_to]": TRADETIME_TO,
"fields[options-eod]": (
"contract,"
"strike,"
"tradetime,"
"dte,"
"theta,"
"moneyness,"
"delta,"
"volatility,"
"midpoint"
),
"page[offset]": offset,
"page[limit]": PAGE_LIMIT
}

response = get_historical_option_data(
API_KEY,
params
)

requests_made += 1
all_rows.extend(response["data"])

if response["links"]["next"] is None:
break

offset += PAGE_LIMIT

if offset > 10000:
raise RuntimeError("Maximum documented pagination offset exceeded.")


print("API requests made:", requests_made)
print("Historical observations retrieved:", len(all_rows))


# 2. Convert the response to a DataFrame
theta_df = pd.DataFrame([
{
"date": row["id"][-10:],
**row["attributes"]
}
for row in all_rows
])

theta_df["date"] = pd.to_datetime(theta_df["date"])

theta_df = (
theta_df
.dropna(subset=["dte", "theta", "moneyness", "strike"])
.query("10 <= dte <= 65")
.copy()
)

theta_df["theta_magnitude"] = theta_df["theta"].abs()


# 3. Keep contracts that span the full 65–10 DTE window
coverage = (
theta_df
.groupby("contract")["dte"]
.agg(
min_dte="min",
max_dte="max",
observations="count"
)
.reset_index()
)

eligible_contracts = coverage.loc[
(coverage["min_dte"] <= 10) &
(coverage["max_dte"] >= 65),
"contract"
]


# 4. Among those contracts, find the ones closest to ATM around 45 DTE
anchor_df = theta_df[
theta_df["contract"].isin(eligible_contracts)
].copy()

anchor_df["distance_from_45_dte"] = (
anchor_df["dte"] - 45
).abs()

anchor_df = (
anchor_df
.sort_values(
["contract", "distance_from_45_dte"]
)
.groupby("contract", as_index=False)
.first()
)

anchor_df["abs_moneyness"] = (
anchor_df["moneyness"].abs()
)

selected_contracts = (
anchor_df
.nsmallest(N_CONTRACTS, "abs_moneyness")
["contract"]
.tolist()
)

selected_df = theta_df[
theta_df["contract"].isin(selected_contracts)
].copy()


print("\nSelected contracts near ATM around 45 DTE:")

display(
anchor_df[
anchor_df["contract"].isin(selected_contracts)
][
["contract", "strike", "dte", "moneyness", "delta"]
]
.sort_values("strike")
)


# 5. Calculate average theta magnitude by DTE
average_theta = (
selected_df
.groupby("dte", as_index=False)["theta_magnitude"]
.mean()
.sort_values("dte", ascending=False)
)

# Smooth only the average line for easier visual interpretation
average_theta["theta_smooth"] = (
average_theta["theta_magnitude"]
.rolling(
window=5,
center=True,
min_periods=1
)
.mean()
)

# 6. Visualize individual contracts + average trend
fig, ax = plt.subplots(figsize=(12, 6))

for contract, group in selected_df.groupby("contract"):

strike = group["strike"].iloc[0]

ax.scatter(
group["dte"],
group["theta_magnitude"],
alpha=0.45,
s=30,
label=f"${strike:g} Call"
)

# Average trend
ax.plot(
average_theta["dte"],
average_theta["theta_smooth"],
color="black",
linewidth=3,
label="Average Theta Magnitude"
)

# Highlight the commonly discussed window
ax.axvspan(
21,
45,
color="gray",
alpha=0.12,
label="45–21 DTE Window"
)

ax.invert_xaxis()

ax.set_title(
"AAPL Sep. 18, 2026 Calls — Theta Magnitude as Expiration Approaches"
)
ax.set_xlabel("Days to Expiration")
ax.set_ylabel("Theta Magnitude")

ax.legend()
plt.tight_layout()
plt.show()
Press enter or click to view image in full size
Image provided by the author

The chart shows that theta magnitude remains relatively stable farther from expiration before increasing more noticeably around 35 DTE. The increase continues as expiration approaches, becoming particularly pronounced below 21 DTE. While this pattern is directionally consistent with accelerating time decay, it is important to recognize that these contracts’ moneyness and implied volatility are also changing. This small sample demonstrates how historical options data can be used to investigate such patterns, rather than establish a universally optimal trading window.

Conclusion

The historical data from the eod endpoint from the US Stock Options Data API from EODHD APIs certainly has a learning curve; however, I believe once one learns to understand how to use it effectively and grasp the key nuances that come with historical options data, it can be a powerful tool in one’s research or trading stack.

  • A long-term options trader could use it to uncover the historical performance of Long-Term Equity Anticipation Securities (LEAPS, which are one of my personal favorites).
  • A trader who sells options could study how different historical entry conditions behaved across volatility and moneyness regimes.
  • A developer or data scientist could use the dataset as a foundation for more rigorous options backtesting research.

To close, here are my two key takeaways from this API: understand the underlying data deeply, and be as strategic as possible in how you ingest it into your tools or research stack.

If you enjoyed this story, please follow me here for more stories on AI, Data Science, Python, Cloud Computing, Machine Learning, Finance, & more

--

--

Jacob Ingle
Jacob Ingle

Written by Jacob Ingle

Lead Data Scientist | AWS Machine Learning Certified | Passion for Quantitative Finance