Earnings are discrete volatility shocks. The stock reprices, implied volatility usually collapses after the release, and execution quality often worsens exactly when trader attention peaks. That makes earnings options attractive, but only if the trade structure matches a measurable hypothesis.
This condensed article presents a research-oriented framework for earnings options using the CuteMarkets API. The central idea is simple:
Do not start with a favorite strategy. Start with the market-implied move, chain quality, and an explicit thesis about direction or volatility.
The examples below use CuteMarkets documented REST endpoints for expirations, option chains, contract snapshots, historical contracts, trades, quotes, and aggregates.
The wrong question is:
What is the best earnings options play?
The better question is:
Given an earnings date, a risk budget, and a market-implied move, which options structure most efficiently expresses my view?
That question breaks into four testable steps:
- Estimate the implied move from the option chain.
- Measure whether quotes and trades are liquid enough to trust.
- Match the hypothesis to a structure.
- Evaluate the result using historical contract and price data.
This is what makes the process scientific rather than anecdotal.
CuteMarkets provides the options data layer:
- expirations
- chain snapshots
- contract snapshots
- contract reference data with historical
as_of - historical trades
- historical quotes
- historical OHLC bars
- single-day option open/close
You still need an external earnings calendar. CuteMarkets gives you the market’s options reaction surface, not the earnings event timestamp itself.
Two practical constraints from the public docs matter immediately:
- Free and Developer plans are delayed.
- The quotes endpoint is documented as Expert-plan only.
That means live earnings execution and serious spread analysis should assume Expert access.
| Task | Endpoint |
|---|---|
| Authenticate | Authorization: Bearer YOUR_API_KEY |
| Get expiries | GET /v1/tickers/expirations/{ticker} |
| Get current chain | GET /v1/options/chain/{ticker} |
| Get one contract snapshot | GET /v1/options/snapshot/{underlying}/{option_contract} |
| Discover historical contracts | GET /v1/options/contracts?as_of=... |
| Get trades | GET /v1/options/trades/{options_ticker} |
| Get quotes | GET /v1/options/quotes/{options_ticker} |
| Get bars | GET /v1/options/aggs/{ticker}/{multiplier}/{timespan}/{from_date}/{to_date} |
| Get single-day close data | GET /v1/options/open-close/{ticker}/{date} |
Reference docs:
- Authentication
- Option Chain Snapshot
- Option Contract Snapshot
- Contracts
- Trades
- Quotes
- Aggregates
- Expirations
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Any
from urllib.parse import quote
import requests
BASE_URL = "https://api.cutemarkets.com/v1"
class CuteMarketsError(RuntimeError):
pass
@dataclass
class CuteMarketsClient:
api_key: str
timeout: int = 30
@property
def headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self.api_key}"}
def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
response = requests.get(
f"{BASE_URL}{path}",
headers=self.headers,
params=params,
timeout=self.timeout,
)
payload = response.json()
if response.status_code >= 400 or payload.get("status") == "ERROR":
error = payload.get("error", {})
raise CuteMarketsError(
f"{response.status_code} {error.get('code', 'unknown_error')}: "
f"{error.get('message', 'Unknown error')}"
)
return payload
cm = CuteMarketsClient(api_key="YOUR_API_KEY")Around earnings, the at-the-money straddle is the simplest market-implied move estimator:
implied_move ~= ATM_call_mid + ATM_put_mid
If the stock price is S, then:
implied_move_pct = implied_move / S
This number is the anchor for almost every earnings options play.
def get_expirations(cm: CuteMarketsClient, ticker: str) -> list[date]:
payload = cm.get(f"/tickers/expirations/{ticker}/")
return [date.fromisoformat(x) for x in payload["results"]]
def nearest_post_earnings_expiry(
cm: CuteMarketsClient,
ticker: str,
earnings_day: date,
*,
after_close: bool = True,
) -> date:
expiries = get_expirations(cm, ticker)
minimum = earnings_day + timedelta(days=1 if after_close else 0)
valid = [expiry for expiry in expiries if expiry >= minimum]
if not valid:
raise ValueError(f"No valid expiry after {minimum.isoformat()}")
return min(valid)
def get_chain(cm: CuteMarketsClient, ticker: str, *, expiration_date: str, limit: int = 100) -> list[dict[str, Any]]:
payload = cm.get(
f"/options/chain/{ticker}/",
params={"expiration_date": expiration_date, "limit": limit},
)
return payload["results"]
def option_mid(contract: dict[str, Any]) -> float:
quote = contract.get("last_quote") or {}
if quote.get("midpoint") is not None:
return float(quote["midpoint"])
bid = quote.get("bid")
ask = quote.get("ask")
if bid is not None and ask is not None:
return (float(bid) + float(ask)) / 2.0
day = contract.get("day") or {}
if day.get("close") is not None:
return float(day["close"])
raise ValueError("No midpoint or daily close available")
def implied_move_from_chain(cm: CuteMarketsClient, ticker: str, earnings_day: date) -> dict[str, Any]:
expiry = nearest_post_earnings_expiry(cm, ticker, earnings_day, after_close=True)
chain = get_chain(cm, ticker, expiration_date=expiry.isoformat(), limit=100)
spot = float(chain[0]["underlying_asset"]["price"])
pairs = {}
for row in chain:
details = row["details"]
strike = float(details["strike_price"])
kind = details["contract_type"]
pairs.setdefault(strike, {})[kind] = row
paired = [
(strike, legs["call"], legs["put"])
for strike, legs in pairs.items()
if "call" in legs and "put" in legs
]
atm_strike, call_row, put_row = min(paired, key=lambda row: abs(row[0] - spot))
call_mid = option_mid(call_row)
put_mid = option_mid(put_row)
implied_move = call_mid + put_mid
return {
"ticker": ticker,
"expiry": expiry.isoformat(),
"spot": spot,
"atm_strike": atm_strike,
"implied_move_abs": implied_move,
"implied_move_pct": implied_move / spot,
"call_delta": call_row.get("greeks", {}).get("delta"),
"put_delta": put_row.get("greeks", {}).get("delta"),
}Example usage:
summary = implied_move_from_chain(cm, "AAPL", date(2026, 7, 30))
print(summary)Interpretation:
- if your forecast move is smaller than the implied move, long premium is harder to justify
- if your forecast move is larger, long gamma or a convex directional structure becomes more interesting
The strategy should follow the hypothesis.
| Hypothesis | Structure |
|---|---|
| Realized move will exceed implied move, direction uncertain | Long straddle or strangle |
| Direction is right, but IV is rich | Debit spread |
| Implied move is overpriced | Short iron condor |
| Front-event IV is richer than back-month IV | Calendar or diagonal |
This framing is more robust than selecting a strategy first and rationalizing it after.
Hypothesis:
Realized move will exceed the move implied by the ATM straddle.
This is a pure long-volatility trade. It is also the most vulnerable to pre-earnings overpayment.
def score_long_straddle(
cm: CuteMarketsClient,
ticker: str,
earnings_day: date,
model_move_pct: float,
) -> dict[str, Any]:
data = implied_move_from_chain(cm, ticker, earnings_day)
edge_pct = model_move_pct - data["implied_move_pct"]
return {
**data,
"model_move_pct": model_move_pct,
"edge_pct": edge_pct,
"upper_breakeven": data["atm_strike"] + data["implied_move_abs"],
"lower_breakeven": data["atm_strike"] - data["implied_move_abs"],
"verdict": "candidate" if edge_pct > 0 else "reject",
}Example:
row = score_long_straddle(
cm,
ticker="NFLX",
earnings_day=date(2026, 7, 16),
model_move_pct=0.085,
)
print(row)Scientific interpretation:
- null hypothesis: realized move will be less than or equal to implied move
- alternative hypothesis: realized move will exceed implied move
If you cannot reject the null with confidence, the long straddle is usually not the right trade.
Around earnings, a naked call or put often overpays for elevated IV. A debit spread partially offsets that problem by selling some event premium back to the market.
def select_call_spread(
cm: CuteMarketsClient,
ticker: str,
earnings_day: date,
*,
long_delta_min: float = 0.25,
long_delta_max: float = 0.50,
) -> dict[str, Any]:
move = implied_move_from_chain(cm, ticker, earnings_day)
expiry = move["expiry"]
spot = move["spot"]
target_short = spot + move["implied_move_abs"]
calls = cm.get(
f"/options/chain/{ticker}/",
params={"expiration_date": expiry, "contract_type": "call", "limit": 100},
)["results"]
long_call = min(
[
c for c in calls
if long_delta_min <= float(c.get("greeks", {}).get("delta") or 0.0) <= long_delta_max
],
key=lambda c: abs(float(c["details"]["strike_price"]) - spot),
)
short_call = min(
[
c for c in calls
if float(c["details"]["strike_price"]) > float(long_call["details"]["strike_price"])
],
key=lambda c: abs(float(c["details"]["strike_price"]) - target_short),
)
long_mid = option_mid(long_call)
short_mid = option_mid(short_call)
width = float(short_call["details"]["strike_price"]) - float(long_call["details"]["strike_price"])
debit = long_mid - short_mid
return {
"long_call": long_call["details"]["ticker"],
"short_call": short_call["details"]["ticker"],
"long_strike": float(long_call["details"]["strike_price"]),
"short_strike": float(short_call["details"]["strike_price"]),
"debit": debit,
"max_profit": width - debit,
"max_loss": debit,
"breakeven": float(long_call["details"]["strike_price"]) + debit,
}Example:
spread = select_call_spread(cm, "MSFT", date(2026, 7, 29))
print(spread)This structure is usually superior to a naked call when:
- you have directional conviction
- the implied move is already expensive
- you can place the short strike near the implied-move boundary
Hypothesis:
The market has overpriced the earnings move, and realized move will stay inside the implied range.
This is a defined-risk short-volatility trade.
def nearest_strike(rows: list[dict[str, Any]], target: float) -> dict[str, Any]:
return min(rows, key=lambda c: abs(float(c["details"]["strike_price"]) - target))
def build_iron_condor(
cm: CuteMarketsClient,
ticker: str,
earnings_day: date,
*,
wing_width: float,
) -> dict[str, Any]:
move = implied_move_from_chain(cm, ticker, earnings_day)
expiry = move["expiry"]
spot = move["spot"]
implied_abs = move["implied_move_abs"]
chain = cm.get(
f"/options/chain/{ticker}/",
params={"expiration_date": expiry, "limit": 100},
)["results"]
calls = [c for c in chain if c["details"]["contract_type"] == "call"]
puts = [p for p in chain if p["details"]["contract_type"] == "put"]
short_call = nearest_strike(calls, spot + implied_abs)
short_put = nearest_strike(puts, spot - implied_abs)
long_call = nearest_strike(calls, float(short_call["details"]["strike_price"]) + wing_width)
long_put = nearest_strike(puts, float(short_put["details"]["strike_price"]) - wing_width)
credit = (
option_mid(short_call)
+ option_mid(short_put)
- option_mid(long_call)
- option_mid(long_put)
)
width = max(
float(long_call["details"]["strike_price"]) - float(short_call["details"]["strike_price"]),
float(short_put["details"]["strike_price"]) - float(long_put["details"]["strike_price"]),
)
return {
"short_call_strike": float(short_call["details"]["strike_price"]),
"short_put_strike": float(short_put["details"]["strike_price"]),
"long_call_strike": float(long_call["details"]["strike_price"]),
"long_put_strike": float(long_put["details"]["strike_price"]),
"credit": credit,
"max_loss": width - credit,
"upper_breakeven": float(short_call["details"]["strike_price"]) + credit,
"lower_breakeven": float(short_put["details"]["strike_price"]) - credit,
}Example:
condor = build_iron_condor(cm, "AMZN", date(2026, 8, 1), wing_width=5.0)
print(condor)Reject the condor if:
- the credit is trivial relative to maximum loss
- short strikes are too close to spot
- spreads are wide enough that midpoint pricing is fantasy
Not every earnings thesis is about outright move magnitude. Sometimes the edge is in term structure.
Calendar hypothesis:
The front expiry contains more event premium than the next expiry, and that premium will partially collapse after earnings.
That is different from a condor. A condor is a short realized-move hypothesis. A calendar is a relative-IV hypothesis across expiries.
def build_call_calendar(
cm: CuteMarketsClient,
ticker: str,
earnings_day: date,
) -> dict[str, Any]:
front_expiry = nearest_post_earnings_expiry(cm, ticker, earnings_day, after_close=True)
expiries = get_expirations(cm, ticker)
later = [x for x in expiries if x > front_expiry]
if not later:
raise ValueError("No back expiry available")
back_expiry = later[0]
front_calls = cm.get(
f"/options/chain/{ticker}/",
params={"expiration_date": front_expiry.isoformat(), "contract_type": "call", "limit": 100},
)["results"]
back_calls = cm.get(
f"/options/chain/{ticker}/",
params={"expiration_date": back_expiry.isoformat(), "contract_type": "call", "limit": 100},
)["results"]
spot = float(front_calls[0]["underlying_asset"]["price"])
front_atm = min(front_calls, key=lambda c: abs(float(c["details"]["strike_price"]) - spot))
strike = float(front_atm["details"]["strike_price"])
back_same_strike = min(back_calls, key=lambda c: abs(float(c["details"]["strike_price"]) - strike))
return {
"strike": strike,
"front_expiry": front_expiry.isoformat(),
"back_expiry": back_expiry.isoformat(),
"front_iv": float(front_atm.get("implied_volatility") or 0.0),
"back_iv": float(back_same_strike.get("implied_volatility") or 0.0),
"net_debit": option_mid(back_same_strike) - option_mid(front_atm),
"short_front_call": front_atm["details"]["ticker"],
"long_back_call": back_same_strike["details"]["ticker"],
}Example:
calendar = build_call_calendar(cm, "META", date(2026, 7, 31))
print(calendar)What to test:
- Is front-month IV materially above back-month IV?
- Is the strike near spot or deliberately directional?
- Is the expected spot gap small enough that the calendar is still fundamentally a vol trade rather than an accidental outright bet?
An earnings trade is only as good as its executable market.
CuteMarkets’ documented quotes endpoint provides historical NBBO-style quote data for a contract. The trades endpoint gives time-and-sales data. Together, they answer a practical question:
Is the option actually tradable at the prices my model assumes?
def get_quotes(
cm: CuteMarketsClient,
option_ticker: str,
*,
ts_gte: str,
ts_lte: str,
limit: int = 500,
) -> list[dict[str, Any]]:
encoded = quote(option_ticker, safe="")
payload = cm.get(
f"/options/quotes/{encoded}/",
params={
"timestamp.gte": ts_gte,
"timestamp.lte": ts_lte,
"limit": limit,
"sort": "timestamp",
"order": "asc",
},
)
return payload["results"]
def mean_relative_spread(quotes: list[dict[str, Any]]) -> float:
values = []
for q in quotes:
bid = float(q["bid_price"])
ask = float(q["ask_price"])
if ask <= 0 or ask < bid:
continue
mid = (bid + ask) / 2.0
values.append((ask - bid) / mid if mid > 0 else 0.0)
return sum(values) / len(values) if values else float("nan")Example:
quotes = get_quotes(
cm,
"O:NFLX260402C00075000",
ts_gte="2026-03-10",
ts_lte="2026-03-10",
limit=1000,
)
print("mean relative spread:", mean_relative_spread(quotes))A reasonable live filter is to reject entries if mean relative spread is persistently above 5% to 10% of midpoint.
def get_trades(
cm: CuteMarketsClient,
option_ticker: str,
*,
ts_gte: str,
ts_lte: str,
limit: int = 500,
) -> list[dict[str, Any]]:
encoded = quote(option_ticker, safe="")
payload = cm.get(
f"/options/trades/{encoded}/",
params={
"timestamp.gte": ts_gte,
"timestamp.lte": ts_lte,
"limit": limit,
"sort": "timestamp",
"order": "asc",
},
)
return payload["results"]
def trade_vwap(trades: list[dict[str, Any]]) -> float:
notional = 0.0
volume = 0.0
for trade in trades:
notional += float(trade["price"]) * float(trade["size"])
volume += float(trade["size"])
return notional / volume if volume else float("nan")If quotes look narrow but trades keep printing at one extreme of the spread, execution quality is worse than the chain snapshot suggests.
One of the most useful documented CuteMarkets features for research is the contracts endpoint with as_of. It allows historical contract discovery as of a pre-event date.
That matters because event studies fail if they accidentally use contracts that did not exist at the time.
def list_contracts_as_of(
cm: CuteMarketsClient,
ticker: str,
*,
as_of: str,
expiration_date: str,
contract_type: str,
) -> list[dict[str, Any]]:
payload = cm.get(
"/options/contracts/",
params={
"underlying_ticker": ticker,
"as_of": as_of,
"expiration_date": expiration_date,
"contract_type": contract_type,
"limit": 1000,
},
)
return payload["results"]Pair that with the documented single-day option open/close route:
def option_open_close(cm: CuteMarketsClient, option_ticker: str, day: str) -> dict[str, Any]:
encoded = quote(option_ticker, safe="")
return cm.get(f"/options/open-close/{encoded}/{day}/")Then a simple historical call-spread study becomes possible:
def historical_call_spread_pnl(
cm: CuteMarketsClient,
*,
ticker: str,
earnings_day: str,
expiry_day: str,
underlying_close: float,
hold_to_day: str,
) -> dict[str, Any]:
calls = list_contracts_as_of(
cm,
ticker,
as_of=earnings_day,
expiration_date=expiry_day,
contract_type="call",
)
long_call = min(calls, key=lambda c: abs(float(c["strike_price"]) - underlying_close))
short_call = min(
[c for c in calls if float(c["strike_price"]) > float(long_call["strike_price"])],
key=lambda c: abs(float(c["strike_price"]) - underlying_close * 1.05),
)
long_entry = float(option_open_close(cm, long_call["ticker"], earnings_day)["close"])
short_entry = float(option_open_close(cm, short_call["ticker"], earnings_day)["close"])
long_exit = float(option_open_close(cm, long_call["ticker"], hold_to_day)["close"])
short_exit = float(option_open_close(cm, short_call["ticker"], hold_to_day)["close"])
entry_debit = long_entry - short_entry
exit_value = long_exit - short_exit
return {
"ticker": ticker,
"long_call": long_call["ticker"],
"short_call": short_call["ticker"],
"entry_debit": entry_debit,
"exit_value": exit_value,
"pnl_per_contract": (exit_value - entry_debit) * 100.0,
}This is enough for a basic but valid event study:
- historically correct contracts
- historically correct option prices
- explicit event date from your own calendar
The real production use-case is not one ticker. It is a watchlist of major earnings names competing for the same capital.
A compact screening layer can rank names by the gap between your modeled move and the market-implied move.
def screen_watchlist(
cm: CuteMarketsClient,
tickers: list[str],
earnings_days: dict[str, date],
model_moves: dict[str, float],
) -> list[dict[str, Any]]:
rows = []
for ticker in tickers:
try:
data = implied_move_from_chain(cm, ticker, earnings_days[ticker])
rows.append(
{
"ticker": ticker,
"expiry": data["expiry"],
"implied_move_pct": data["implied_move_pct"],
"model_move_pct": model_moves[ticker],
"edge_pct": model_moves[ticker] - data["implied_move_pct"],
}
)
except Exception as exc:
rows.append({"ticker": ticker, "error": str(exc)})
return sorted(
rows,
key=lambda row: row.get("edge_pct", float("-inf")),
reverse=True,
)Example:
watchlist = ["AAPL", "AMZN", "GOOGL", "META", "MSFT", "NFLX", "NVDA"]
earnings_days = {
"AAPL": date(2026, 7, 30),
"AMZN": date(2026, 8, 1),
"GOOGL": date(2026, 7, 23),
"META": date(2026, 7, 31),
"MSFT": date(2026, 7, 29),
"NFLX": date(2026, 7, 16),
"NVDA": date(2026, 8, 20),
}
model_moves = {
"AAPL": 0.045,
"AMZN": 0.072,
"GOOGL": 0.065,
"META": 0.083,
"MSFT": 0.052,
"NFLX": 0.095,
"NVDA": 0.102,
}
for row in screen_watchlist(cm, watchlist, earnings_days, model_moves):
print(row)This is where the teaser connects back to practice. A good earnings options workflow is rarely "find one trade." It is "rank many event candidates, reject the names with poor execution quality, and deploy capital only where the structure and data agree."
If you want results that can be compared across strategies, each event should produce the same core fields.
At minimum, record:
- ticker
- earnings date and whether the release was before open or after close
- chosen expiry
- underlying spot at entry
- implied move in dollars and percent
- structure type
- entry debit or credit
- maximum gain and maximum loss
- exit value
- per-contract P/L
One compact way to standardize that is:
def make_event_result(
*,
ticker: str,
earnings_day: str,
expiry_day: str,
structure: str,
spot_entry: float,
implied_move_abs: float,
implied_move_pct: float,
entry_value: float,
exit_value: float,
max_loss: float,
) -> dict[str, float | str]:
return {
"ticker": ticker,
"earnings_day": earnings_day,
"expiry_day": expiry_day,
"structure": structure,
"spot_entry": spot_entry,
"implied_move_abs": implied_move_abs,
"implied_move_pct": implied_move_pct,
"entry_value": entry_value,
"exit_value": exit_value,
"pnl_per_contract": (exit_value - entry_value) * 100.0,
"return_on_risk": ((exit_value - entry_value) / max_loss) if max_loss > 0 else 0.0,
}Without a normalized event schema, strategy comparisons degrade into narrative. With it, you can compare straddles, debit spreads, condors, and calendars on the same footing.
Any serious teaser should still be honest about limitations.
- Delayed data changes the experiment for live earnings trading.
- Midpoint assumptions can overstate backtest quality.
- Earnings date alone is insufficient; you need before-open versus after-close timing.
- Illiquid option bars may be sparse because aggregates are built from qualifying trades.
- CuteMarkets supplies the options data, but not the earnings calendar itself.
If you want a disciplined workflow, use this sequence:
- Get the earnings date and exact timing from your event source.
- Use CuteMarkets expirations and chain data to estimate the implied move.
- Decide whether your hypothesis is long move, short move, or directional.
- Reject names with poor quote quality or weak trade flow.
- Choose the structure:
- straddle if you expect realized move to exceed implied move
- debit spread if you have direction and want to reduce IV overpayment
- condor if you believe implied move is too rich
- calendar if event IV is concentrated in the front expiry
- Use historical contracts plus open/close or aggregates to evaluate the trade family over past events.
The central lesson is that earnings options should be treated as a measurement problem first and a strategy problem second.
CuteMarkets gives you the market-data pieces needed to do that rigorously:
- the chain for implied move and Greeks
- quotes and trades for execution-quality filters
- contracts with
as_offor historical reconstruction - bars and open/close data for event studies
That is enough to build a serious earnings-options research workflow, even in condensed form.
- CuteMarkets, Authentication
- CuteMarkets, Option Chain Snapshot
- CuteMarkets, Option Contract Snapshot
- CuteMarkets, Contracts
- CuteMarkets, Quotes
- CuteMarkets, Trades
- CuteMarkets, Aggregates
- CuteMarkets, Expirations