HomeBlogEarnings Options Plays, Scientifically: Measuring Implied Move, IV Crush, and Execution Quality with CuteMarkets
Deep DiveApril 18, 2026·20 min read

Earnings Options Plays, Scientifically: Measuring Implied Move, IV Crush, and Execution Quality with CuteMarkets

CuteMarkets

CuteMarkets Team

Research

Earnings Options Plays, Scientifically: Measuring Implied Move, IV Crush, and Execution Quality with CuteMarkets

Earnings Options Plays, Scientifically: Measuring Implied Move, IV Crush, and Execution Quality with CuteMarkets

Abstract

Earnings announcements are discrete volatility events. The underlying reprices on new information, implied volatility typically collapses immediately after the release, and execution quality often deteriorates exactly when retail traders become most active. This combination makes earnings options trades attractive and dangerous at the same time.

This post presents a research-style framework for studying and trading options around earnings with the CuteMarkets API. The emphasis is on measurable quantities:

  • the market-implied move from the at-the-money straddle
  • the relationship between directional conviction and option structure selection
  • the role of term structure in calendars and diagonals
  • the importance of quote width and trade flow near entry
  • the difference between a mathematically valid options play and an executable one

The examples are intentionally practical. Every code snippet uses documented CuteMarkets endpoints, and every strategy is written so it can be screened, audited, or backtested.

1. Research Question

The central question is not "what is the best earnings trade?"

The better scientific question is:

Given an earnings date, a risk budget, and an options chain, which structure best expresses a view on direction, realized move, and volatility crush?

That question naturally decomposes into four sub-problems:

  1. Estimate the market-implied move.
  2. Measure chain quality and liquidity.
  3. Map a thesis to a structure.
  4. Evaluate post-event payoff with realistic pricing inputs.

2. Scope and Assumptions

This article is deliberately precise about scope.

  • CuteMarkets provides the options layer: chains, contract metadata, snapshots, trades, quotes, and aggregates.
  • The earnings calendar itself is assumed to come from your own event source.
  • All premiums are per share. Multiply by 100 for per-contract dollar values.
  • According to CuteMarkets' plan documentation, Free and Developer plans are delayed, while Expert is live and includes the quotes endpoint. That matters for any live earnings execution workflow.
  • The historical examples below use documented options endpoints only. If you want a fully self-contained event study, you still need an external source for earnings timestamps and, in many cases, underlying spot history.

3. Endpoint Map

The core endpoints used in this post are:

PurposeEndpoint
AuthenticationAuthorization: Bearer YOUR_API_KEY
Expirations for an underlyingGET /v1/tickers/expirations/{ticker}
Current chain snapshotGET /v1/options/chain/{ticker}
Single-contract snapshotGET /v1/options/snapshot/{underlying}/{option_contract}
Historical contract discoveryGET /v1/options/contracts with as_of
Historical tradesGET /v1/options/trades/{options_ticker}
Historical quotesGET /v1/options/quotes/{options_ticker}
Historical OHLC barsGET /v1/options/aggs/{ticker}/{multiplier}/{timespan}/{from_date}/{to_date}
Single-day open/closeGET /v1/options/open-close/{ticker}/{date}

Official references:

4. Experimental Setup

We start with a thin Python client. The goal is not abstraction for its own sake. The goal is reproducibility.

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]:
        url = f"{BASE_URL}{path}"
        response = requests.get(url, 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")

Two convenience helpers are useful immediately.

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 get_chain(
    cm: CuteMarketsClient,
    ticker: str,
    *,
    expiration_date: str | None = None,
    contract_type: str | None = None,
    strike_gte: float | None = None,
    strike_lte: float | None = None,
    limit: int = 100,
) -> list[dict[str, Any]]:
    params: dict[str, Any] = {"limit": limit}
    if expiration_date is not None:
        params["expiration_date"] = expiration_date
    if contract_type is not None:
        params["contract_type"] = contract_type
    if strike_gte is not None:
        params["strike_price.gte"] = strike_gte
    if strike_lte is not None:
        params["strike_price.lte"] = strike_lte

    payload = cm.get(f"/options/chain/{ticker}/", params=params)
    return payload["results"]

5. Step One in Any Earnings Trade: Estimate the Implied Move

The cleanest first approximation is the at-the-money straddle:

expected_move ~= ATM_call_mid + ATM_put_mid

If spot is S, then the relative implied move is:

expected_move_pct = expected_move / S

That one number is the anchor for almost every earnings options play.

5.1 Choosing the correct expiry

If the company reports after the close, the relevant expiry must survive the overnight information shock. For a weekly setup, that usually means the first expiry on or after earnings_date + 1 day.

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)
    candidates = [expiry for expiry in expiries if expiry >= minimum]
    if not candidates:
        raise ValueError(f"No valid expiry found for {ticker} after {minimum.isoformat()}")
    return min(candidates)

5.2 Computing the straddle implied move

The chain snapshot includes contract details, Greeks, implied volatility, and latest quote or trade context when your plan supports it. That is enough to build an ATM straddle estimator.

def option_mid(contract: dict[str, Any]) -> float:
    quote = contract.get("last_quote") or {}
    if "midpoint" in quote and quote["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("Contract has neither midpoint nor daily close")


def pair_by_strike(chain: list[dict[str, Any]]) -> dict[float, dict[str, dict[str, Any]]]:
    out: dict[float, dict[str, dict[str, Any]]] = {}
    for row in chain:
        details = row["details"]
        strike = float(details["strike_price"])
        kind = details["contract_type"]
        out.setdefault(strike, {})[kind] = row
    return out


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)

    if not chain:
        raise ValueError(f"Empty chain for {ticker} {expiry.isoformat()}")

    spot = float(chain[0]["underlying_asset"]["price"])
    by_strike = pair_by_strike(chain)

    paired = [
        (strike, legs["call"], legs["put"])
        for strike, legs in by_strike.items()
        if "call" in legs and "put" in legs
    ]
    if not paired:
        raise ValueError("No call/put pairs found")

    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,
        "spot": spot,
        "expiry": expiry.isoformat(),
        "atm_strike": atm_strike,
        "call_mid": call_mid,
        "put_mid": put_mid,
        "implied_move_abs": implied_move,
        "implied_move_pct": implied_move / spot,
        "call_iv": call_row.get("implied_volatility"),
        "put_iv": put_row.get("implied_volatility"),
        "call_delta": call_row.get("greeks", {}).get("delta"),
        "put_delta": put_row.get("greeks", {}).get("delta"),
    }

Example usage:

earnings_day = date(2026, 5, 7)  # example input from your own calendar
summary = implied_move_from_chain(cm, "AAPL", earnings_day)
print(summary)

Interpretation:

  • implied_move_abs is the market's approximate dollar move.
  • implied_move_pct is the same number scaled by spot.
  • if your independent forecast of realized move is smaller than the implied move, long premium is harder to justify.
  • if your independent forecast is larger, long gamma may be reasonable.

6. Example Play 1: The Long Straddle

The long straddle is the cleanest expression of the view:

"I do not know direction, but I believe realized move will exceed the market-implied move."

This is a pure long-vol expression. It is also the most vulnerable to pre-earnings overpricing.

6.1 Screening logic

A straddle is rarely attractive if:

  • the combined premium is already larger than your forecast move
  • the bid/ask spread is wide relative to midpoint
  • the event is too far away and theta bleed dominates
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"]

    upper_breakeven = data["atm_strike"] + data["implied_move_abs"]
    lower_breakeven = data["atm_strike"] - data["implied_move_abs"]

    return {
        **data,
        "model_move_pct": model_move_pct,
        "edge_pct": edge_pct,
        "upper_breakeven": upper_breakeven,
        "lower_breakeven": lower_breakeven,
        "verdict": "candidate" if edge_pct > 0 else "reject",
    }

Example:

candidate = score_long_straddle(
    cm,
    ticker="NFLX",
    earnings_day=date(2026, 7, 16),
    model_move_pct=0.085,  # 8.5% forecast from your own event model
)
print(candidate["verdict"], candidate["edge_pct"])

6.2 Scientific interpretation

A straddle is a hypothesis test:

  • null hypothesis: realized move will be less than or equal to implied move
  • alternative hypothesis: realized move will exceed implied move

The straddle only makes sense when you reject the null with real confidence, not when you merely "feel" the stock will move.

7. Example Play 2: A Directional Debit Spread

A debit spread is often a better earnings trade than a naked call or naked put because it offsets some pre-event volatility premium.

Bullish thesis:

  • buy a call with meaningful delta
  • sell a higher strike near or slightly beyond the implied move boundary

Bearish thesis:

  • buy a put with meaningful absolute delta
  • sell a lower strike near or slightly beyond the implied move boundary

7.1 Why debit spreads work well around earnings

Before earnings, the main problem with outright long options is not just direction risk. It is paying elevated implied volatility. A spread sells some of that volatility back to the market.

7.2 Building a bullish call spread

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_strike = spot + move["implied_move_abs"]

    calls = get_chain(cm, ticker, expiration_date=expiry, contract_type="call", limit=100)

    liquid_calls = [
        c for c in calls
        if c.get("last_quote") or c.get("day")
    ]

    long_call = min(
        [
            c for c in liquid_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 liquid_calls
            if float(c["details"]["strike_price"]) > float(long_call["details"]["strike_price"])
        ],
        key=lambda c: abs(float(c["details"]["strike_price"]) - target_short_strike),
    )

    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 {
        "ticker": ticker,
        "expiry": expiry,
        "spot": spot,
        "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))
for k, v in spread.items():
    print(f"{k}: {v}")

7.3 When the call spread is superior to the straddle

Use the call spread when:

  • you have directional conviction
  • you do not need an outsized two-sided move
  • the implied move is expensive
  • the short strike can be placed near the upper implied-move boundary

The same logic applies symmetrically to bearish put spreads.

8. Example Play 3: A Short Iron Condor

The short iron condor expresses the view:

"The implied move is overpriced, and realized move will remain inside the market's expected range."

Scientifically, this is a short-volatility hypothesis with bounded tail risk.

8.1 Constructing the condor around the implied move

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 = get_chain(cm, ticker, expiration_date=expiry, limit=100)
    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)
    )

    call_width = float(long_call["details"]["strike_price"]) - float(short_call["details"]["strike_price"])
    put_width = float(short_put["details"]["strike_price"]) - float(long_put["details"]["strike_price"])
    width = max(call_width, put_width)
    max_loss = width - credit

    return {
        "ticker": ticker,
        "expiry": expiry,
        "spot": spot,
        "short_call": short_call["details"]["ticker"],
        "short_put": short_put["details"]["ticker"],
        "long_call": long_call["details"]["ticker"],
        "long_put": long_put["details"]["ticker"],
        "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": max_loss,
        "upper_breakeven": float(short_call["details"]["strike_price"]) + credit,
        "lower_breakeven": float(short_put["details"]["strike_price"]) - credit,
    }

Example:

condor = build_iron_condor(
    cm,
    ticker="AMZN",
    earnings_day=date(2026, 8, 1),
    wing_width=5.0,
)
print(condor)

8.2 Guardrails

A condor should be rejected if:

  • entry credit is trivial relative to maximum loss
  • short strikes are inside the implied move rather than outside it
  • quote widths are too wide to trust midpoint pricing
  • volume and open interest are thin

That last point is critical. A "winning" backtest based on stale midpoint assumptions can be a losing live strategy.

9. Example Play 4: A Calendar Spread for Event IV

Calendars are structurally interesting around earnings because the front expiry often contains the event premium more directly than the back expiry.

Research hypothesis:

Short the most event-loaded expiry, buy the less event-loaded back-month expiry at the same strike.

This is not a pure direction trade. It is a view on relative term structure and post-event volatility normalization.

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_expiries = [x for x in expiries if x > front_expiry]
    if not later_expiries:
        raise ValueError("No back expiry available")
    back_expiry = later_expiries[0]

    front_calls = get_chain(cm, ticker, expiration_date=front_expiry.isoformat(), contract_type="call", limit=100)
    back_calls = get_chain(cm, ticker, expiration_date=back_expiry.isoformat(), contract_type="call", limit=100)

    if not front_calls or not back_calls:
        raise ValueError("Missing chain on one side of the calendar")

    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),
    )

    debit = option_mid(back_same_strike) - option_mid(front_atm)
    front_iv = float(front_atm.get("implied_volatility") or 0.0)
    back_iv = float(back_same_strike.get("implied_volatility") or 0.0)

    return {
        "ticker": ticker,
        "spot": spot,
        "strike": strike,
        "short_front_call": front_atm["details"]["ticker"],
        "long_back_call": back_same_strike["details"]["ticker"],
        "front_expiry": front_expiry.isoformat(),
        "back_expiry": back_expiry.isoformat(),
        "front_iv": front_iv,
        "back_iv": back_iv,
        "iv_slope": front_iv - back_iv,
        "net_debit": debit,
    }

Why this matters:

  • if front-month IV is dramatically richer than back-month IV, the event premium is concentrated
  • if the slope is flat, the calendar edge is weaker
  • if spot gaps violently through the strike, the calendar becomes a direction trade whether you intended it or not

10. Example Play 5: Quote Quality as an Entry Filter

CuteMarkets' chain endpoint can include latest quote and trade context, but the dedicated quotes endpoint is the cleaner tool for liquidity analysis. The official docs state that /v1/options/quotes/{options_ticker} requires the Expert plan.

For live earnings trading, this distinction is not minor. It is fundamental.

10.1 Measuring average spread before entry

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 quote_spread_stats(quotes: list[dict[str, Any]]) -> dict[str, float]:
    spreads = []
    rel_spreads = []
    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
        spread = ask - bid
        spreads.append(spread)
        rel_spreads.append(spread / mid if mid > 0 else 0.0)

    if not spreads:
        return {"mean_spread": float("nan"), "mean_rel_spread": float("nan")}

    return {
        "mean_spread": sum(spreads) / len(spreads),
        "mean_rel_spread": sum(rel_spreads) / len(rel_spreads),
    }

Example:

stats = quote_spread_stats(
    get_quotes(
        cm,
        "O:NFLX260402C00075000",
        ts_gte="2026-03-10",
        ts_lte="2026-03-10",
        limit=1000,
    )
)
print(stats)

A simple live-trading rule:

  • reject entries if mean relative spread exceeds 5% to 10% of midpoint
  • reject entries if spread instability spikes in the final minutes before the release

11. Example Play 6: Trade Tape Diagnostics

The historical trades endpoint is useful for a different question:

Are prints actually occurring near the quoted mid, or is the market becoming one-sided?

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:
    numerator = 0.0
    denominator = 0.0
    for trade in trades:
        price = float(trade["price"])
        size = float(trade["size"])
        numerator += price * size
        denominator += size
    return numerator / denominator if denominator else float("nan")

Example:

trades = get_trades(
    cm,
    "O:NFLX260402C00075000",
    ts_gte="2026-03-10",
    ts_lte="2026-03-10",
)
print("VWAP:", trade_vwap(trades))
print("Trade count:", len(trades))

This is useful for earnings because low quote width is not enough. You want:

  • narrow spreads
  • enough trade frequency
  • no evidence that fills are clustering only at the offer for longs or only at the bid for shorts

12. Example Play 7: Historical Event-Window Bars for One Contract

For post-trade evaluation, the aggregates endpoints are a clean way to reconstruct option behavior before and after earnings.

def get_option_bars(
    cm: CuteMarketsClient,
    option_ticker: str,
    *,
    start_day: str,
    end_day: str,
    multiplier: int = 1,
    timespan: str = "day",
    limit: int = 5000,
) -> list[dict[str, Any]]:
    encoded = quote(option_ticker, safe="")
    path = f"/options/aggs/{encoded}/{multiplier}/{timespan}/{start_day}/{end_day}/"
    payload = cm.get(path, params={"limit": limit, "sort": "asc"})
    return payload["results"]

Example:

bars = get_option_bars(
    cm,
    "O:NFLX260402C00075000",
    start_day="2026-03-01",
    end_day="2026-03-20",
)
print(bars[:3])

Two scientifically important notes from the CuteMarkets aggregates docs:

  • bars are built from qualifying trades
  • intervals with no qualifying trades are omitted

So missing bars mean "no qualifying trades," not "zero return."

13. Example Play 8: A Historical Debit-Spread Event Study

This is where event research becomes real.

CuteMarkets' contracts endpoint supports as_of, which is essential when you want the contract universe as it existed before a historical earnings event.

Below is a minimal event-study skeleton:

  • input: external table with ticker, earnings_day, underlying_close
  • use CuteMarkets to discover historical contracts as of earnings_day
  • pick strikes relative to the underlying close
  • use CuteMarkets daily open/close or aggregate bars to price the spread
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"]


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}/")


def choose_nearest_contract(contracts: list[dict[str, Any]], target_strike: float) -> dict[str, Any]:
    return min(contracts, key=lambda c: abs(float(c["strike_price"]) - target_strike))

Now the event-study loop:

def historical_bull_call_spread_return(
    cm: CuteMarketsClient,
    *,
    ticker: str,
    earnings_day: str,
    expiry_day: str,
    underlying_close: float,
    hold_to_day: str,
    long_offset_pct: float = 0.00,
    short_offset_pct: float = 0.05,
) -> dict[str, Any]:
    calls = list_contracts_as_of(
        cm,
        ticker,
        as_of=earnings_day,
        expiration_date=expiry_day,
        contract_type="call",
    )
    if not calls:
        raise ValueError("No calls found")

    long_target = underlying_close * (1.0 + long_offset_pct)
    short_target = underlying_close * (1.0 + short_offset_pct)

    long_call = choose_nearest_contract(calls, long_target)
    short_call = choose_nearest_contract(calls, short_target)

    long_entry = option_open_close(cm, long_call["ticker"], earnings_day)["close"]
    short_entry = option_open_close(cm, short_call["ticker"], earnings_day)["close"]
    long_exit = option_open_close(cm, long_call["ticker"], hold_to_day)["close"]
    short_exit = option_open_close(cm, short_call["ticker"], hold_to_day)["close"]

    entry_debit = float(long_entry) - float(short_entry)
    exit_value = float(long_exit) - float(short_exit)

    return {
        "ticker": ticker,
        "earnings_day": earnings_day,
        "expiry_day": expiry_day,
        "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 not a full institutional backtester, but it is already scientifically useful:

  • contracts are historically correct via as_of
  • option prices come from documented historical endpoints
  • the earnings timestamp remains an explicit external input instead of a hidden assumption

14. Example Play 9: Batch Screening a Watchlist

A realistic workflow scans many names before deciding which earnings event deserves capital.

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:
            row = score_long_straddle(
                cm,
                ticker=ticker,
                earnings_day=earnings_days[ticker],
                model_move_pct=model_moves[ticker],
            )
            rows.append(row)
        except Exception as exc:
            rows.append(
                {
                    "ticker": ticker,
                    "error": str(exc),
                }
            )
    return rows

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,
}

rows = screen_watchlist(cm, watchlist, earnings_days, model_moves)
for row in rows:
    print(row)

This gives you a first-pass ranking:

  • names where implied move is already too rich
  • names where your forecast still exceeds implied
  • names where the data is tradable versus merely interesting

15. A Scientific Taxonomy of Earnings Plays

The strategies above can be organized by hypothesis.

StrategyPrimary hypothesisBest whenMain failure mode
Long straddleRealized move > implied moveVery uncertain direction, high event convexityIV overpayment
Debit spreadDirection is right, but IV is expensiveDirectional conviction with bounded riskMove is too small
Iron condorRealized move < implied movePremium inflated, move likely containedGap through short strikes
CalendarFront event IV is richer than back IVEvent premium concentrated in front expiryLarge spot gap
DiagonalTerm-structure view plus mild directionYou want longer gamma, shorter event premiumWrong direction plus IV normalization

This table is not just conceptual. It tells you what variable to test:

  • straddle: compare forecast realized move vs implied move
  • debit spread: compare directional probability vs premium paid
  • condor: compare realized move distribution vs short strikes
  • calendar: compare front-back term structure

16. Threats to Validity

Any serious earnings study should explicitly state its limitations.

16.1 Delayed versus live data

CuteMarkets documents that Free and Developer plans are delayed, and that Expert provides live data and quotes. If you are executing around the release itself, delayed data is not a small caveat. It changes the experiment.

16.2 Midpoint optimism

Midpoint fills are usually too optimistic in stressed earnings conditions. Use quotes and trades to estimate realistic slippage.

16.3 Missing earnings clock precision

"Earnings date" is not enough. You need to know whether the report is before open or after close. The chosen expiry and hold period depend on that.

16.4 Sparse option trading

CuteMarkets' aggregates documentation is clear that intervals without qualifying trades are omitted. Illiquid contracts can therefore look smoother than they really are if you do not account for missing prints.

16.5 External event data

CuteMarkets supplies the options market data. Your event timestamps, earnings calendar, and any fundamental forecast model must come from outside the options API.

17. Practical Playbook

If you wanted a disciplined production workflow, it would look like this:

  1. Ingest earnings dates from your event calendar.
  2. Use GET /v1/tickers/expirations/{ticker} to find valid post-event expiries.
  3. Use GET /v1/options/chain/{ticker} to estimate implied move and inspect Greeks.
  4. Reject names with poor liquidity or weak term structure.
  5. Map thesis to structure:
    • uncertain direction and large forecast move: straddle or strangle
    • directional thesis and rich IV: debit spread
    • overpriced implied move and liquid market: iron condor
    • concentrated front-month event premium: calendar
  6. Use quotes and trades to validate execution quality before entry.
  7. Use aggregates and open/close data for post-event evaluation and backtests.

18. Conclusion

The most important lesson in earnings options trading is that structure selection should follow measurement, not intuition.

With CuteMarkets, the measurement layer is strong:

  • expirations for event-aware expiry selection
  • chain snapshots for implied move, Greeks, and IV
  • contract metadata with as_of for historical reconstruction
  • trades and quotes for execution-quality analysis
  • aggregate bars for backtests and post-event review

That is enough to build a serious earnings research stack.

What it does not do automatically is give you a thesis. You still need an earnings calendar, a forecast model, and strict risk rules. But once those exist, the API surface is sufficient for screening, structuring, and evaluating the core earnings play families.

References