Introducing

Build a 0-DTE Covered Call Screener for SPY with Polygon.io

Sep 30, 2025

In this demo, we’ll build a small  Python utility that pulls only the option contracts you’d consider for a covered call expiring today (0-DTE) on SPY. Along the way, we’ll explain not just what to do, but why each step matters—especially if you’re new to options or market data.

Who this is for: developers comfortable with Python who want to automate an options workflow—even if you’re still learning options.

You must have an Polygon Options Advanced plan for this demo to work properly.


What you’ll build

  • A 0-DTE SPY covered-call screener that:
    • Pulls the options chain snapshot for SPY expiring today.
    • Filters to call options that are out-of-the-money (OTM) and near a ~30-delta window (adjustable).
    • Checks basic liquidity and quote quality, and computes breakeven, max profit, and a lightweight probability of profit (POP) estimate.
    • Ranks candidates by a simple yield metric and saves a CSV you can use immediately for trade prep—or archive for backtesting.

You’ll also get an optional “mark” mode to take a previously saved CSV and compute realized P&L after the close.

You can find the full code for this demo in this GitHub repository.


What are covered calls?

A covered call means you own the stock (100 shares) and sell a call option on those shares. You collect premium up front—that’s income. If the stock finishes below your strike at expiration, you keep the premium and your shares (best case for income). If it finishes above your strike, you’ll likely be assigned and sell your shares at the strike; you still keep the premium, but your upside is capped. You’re still exposed to the stock’s downside, but the premium cushions smaller dips.

With 0-DTE options (expiring today), premiums can look small but time decay is fastest. That makes screening and timing important: small differences in quotes and spreads matter a lot, and simple safeguards help you avoid “ghost” quotes that aren’t really tradable.


Prerequisites


Strategy settings you can edit (no code surgery)

We expose simple options so you can tune behavior from the command line:

Setting

Meaning

Good starting point

symbol

Underlying ticker

SPY

expiration_days

0 for 0-DTE, 1 for tomorrow, 5 for days from today

0

min_otm_pct

Lower OTM bound for calls (≥ spot)

0.00

max_otm_pct

Upper OTM bound

0.01–0.05

delta_lo/hi

Delta band for selection

0.15–0.35

min_bid

Ignore tiny credits

0.03–0.10

min_open_interest

Liquidity floor

1–100

max_spread_to_mid

Skip quotes with too-wide spreads relative to mid

0.75

rank_metric

Sorting metric (default premium_yield)

premium_yield

outdir

Where to save CSV

./data


Authenticate the client

Before we touch market data, we need a logged-in client that’s safe to run locally and in automation (CI/CRON). Reading the API key from an environment variable keeps secrets out of your codebase and makes rotation painless.

import os
from polygon import RESTClient

def make_client():
    return RESTClient(api_key=os.getenv("POLYGON_API_KEY"))


Work in New York time (the exchange’s clock)

“0-DTE” means “today” on the exchange calendar, not your laptop’s local time. If you run the script from a different timezone, you could accidentally pull the wrong day. We’ll standardize to America/New_York, compute today’s date, and estimate the remaining time to the close. That’s also useful later for rough probability estimates.

from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo

ET = ZoneInfo("America/New_York")

def today_et() -> datetime:
    return datetime.now(ET)

def target_expiration_date(days_ahead: int) -> str:
    d = today_et().date() + timedelta(days=days_ahead)
    return d.strftime("%Y-%m-%d")

def minutes_to_close_on(date_str: str) -> float:
    d = datetime.strptime(date_str, "%Y-%m-%d").date()
    close_dt = datetime.combine(d, time(16, 0), tzinfo=ET)
    now = today_et()
    return max(0.0, (close_dt - now).total_seconds() / 60.0)

def time_to_expiry_years(date_str: str) -> float:
    mins = minutes_to_close_on(date_str)
    return max(mins / (60 * 24 * 365), 1e-6)  # keep small, never zero


Pull the option chain snapshot for today’s calls

A snapshot call returns what you need to screen in one pass: quotes (bid/ask), greeks (like delta), open interest, implied volatility, and the underlying’s price. That keeps the workflow fast and avoids stitching multiple endpoints. We’ll ask only for calls expiring today.

def fetch_chain_snapshot_calls(client, symbol: str, expiration_date: str):
    items = []
    for o in client.list_snapshot_options_chain(
        symbol,
        params={
            "contract_type": "call",
            "expiration_date.gte": expiration_date,
            "expiration_date.lte": expiration_date,
        },
    ):
        items.append(o)
    return items


Resolve the underlying price (spot)

Almost every decision depends on spot: OTM boundaries, relative yield, breakeven, and max profit. The snapshot usually includes the underlying’s price; if not, we’ll fall back to a last trade lookup so the script remains robust.

def resolve_spot(chain, client, symbol: str) -> float | None:
    for o in chain:
        ua = getattr(o, "underlying_asset", None)
        if ua and getattr(ua, "price", None) is not None:
            return ua.price
    lt = client.get_last_trade(symbol)
    return getattr(lt, "price", None)


Filter to realistic covered-call candidates and compute key numbers

Here we translate strategy ideas into rules:

  • OTM band makes sure calls sit above spot, leaving room for the stock to rise.
  • A delta window (if greeks are available) keeps us near a “30-delta” sweet spot: a common compromise between premium size and room for the stock to move.
  • Quote sanity and a spread guard avoid ghost quotes and credits that disappear when you hit send.
  • Open interest helps avoid illiquid series.
  • We compute breakeven, max profit, and a lightweight probability of profit (POP) estimate to make rows comparable.
import math

def norm_cdf(x: float) -> float:
    return 0.5 * (1.0 + math.erf(x / math.sqrt(2)))

def midpoint(bid: float | None, ask: float | None) -> float | None:
    if bid is None or ask is None or bid <= 0 or ask <= 0 or ask < bid:
        return None
    return 0.5 * (bid + ask)

def pop_estimate(S0: float, breakeven: float, iv: float | None, t_years: float) -> float | None:
    if iv is None or iv <= 0 or breakeven <= 0 or t_years <= 0:
        return None
    # Rough probability S_T >= breakeven near expiry (risk-neutral, r≈0)
    d2 = (math.log(S0 / breakeven) - 0.5 * (iv ** 2) * t_years) / (iv * math.sqrt(t_years))
    return norm_cdf(d2)

def screen_candidates(chain,
                      spot: float,
                      expiration_date: str,
                      min_otm_pct=0.00,
                      max_otm_pct=0.03,
                      delta_lo=0.15,
                      delta_hi=0.35,
                      min_bid=0.05,
                      min_oi=1,
                      max_spread_to_mid=0.75,
                      rank_metric="premium_yield"):
    lo = spot * (1 + min_otm_pct)
    hi = spot * (1 + max_otm_pct) if max_otm_pct else float("inf")
    t_years = time_to_expiry_years(expiration_date)

    rows = []
    for o in chain:
        d = getattr(o, "details", None)
        q = getattr(o, "last_quote", None)
        g = getattr(o, "greeks", None)
        oi = getattr(o, "open_interest", 0) or 0
        iv = getattr(o, "implied_volatility", None)

        if not d or not q:
            continue

        k = d.strike_price
        if k is None or not (k >= lo and k <= hi):
            continue

        bid, ask = q.bid, q.ask
        if bid is None or ask is None or bid < min_bid:
            continue

        m = midpoint(bid, ask)
        if m is None or m <= 0:
            continue

        spread = ask - bid
        if m > 0 and (spread / m) > max_spread_to_mid:
            continue

        delta_ok = True
        delta_val = None
        if g and getattr(g, "delta", None) is not None:
            delta_val = abs(g.delta)
            delta_ok = (delta_lo <= delta_val <= delta_hi)
        if not delta_ok:
            continue

        breakeven = spot - m
        max_profit = (k - spot) + m  # if called away
        pop = pop_estimate(spot, breakeven, iv, t_years)

        rows.append({
            "ticker": d.ticker,
            "expiration": d.expiration_date,
            "strike": k,
            "delta": delta_val,
            "bid": bid,
            "ask": ask,
            "mid": m,
            "open_interest": oi,
            "iv": iv,
            "spot": spot,
            "premium_yield": m / spot,
            "breakeven": breakeven,
            "max_profit": max_profit,
            "pop_est": pop,
        })

    # Rank by your chosen metric
    if rank_metric == "premium_yield":
        rows.sort(key=lambda r: r["premium_yield"], reverse=True)
    elif rank_metric == "max_profit":
        rows.sort(key=lambda r: r["max_profit"], reverse=True)
    elif rank_metric == "pop_est":
        rows.sort(key=lambda r: (r["pop_est"] or 0), reverse=True)
    else:
        rows.sort(key=lambda r: r["premium_yield"], reverse=True)

    return rows


Save to CSV (your universal adapter)

CSV works everywhere: spreadsheets, notebooks, dashboards, and batch research. Naming the file with the symbol and date makes it easy to build a historical dataset over time.

import pandas as pd
from pathlib import Path

def save_csv(rows, outdir: str, symbol: str, expiration_date: str) -> str:
    Path(outdir).mkdir(parents=True, exist_ok=True)
    df = pd.DataFrame(rows)
    path = Path(outdir) / f"{symbol.lower()}_{expiration_date}_0dte_calls.csv"
    df.to_csv(path, index=False)
    return str(path)

Optional “mark” mode: compute realized P&L after the close

This turns your morning CSV into ground truth after the close. For each row, the covered-call result per share is:

  • If the stock finishes below the strike: (S_close − S0) + premium
  • If the stock finishes above the strike: (strike − S0) + premium (you’re called away)
def mark_realized_pnl(csv_path: str, underlying_close: float) -> str:
    df = pd.read_csv(csv_path)
    S_close = float(underlying_close)
    per_share = []
    assigned = []
    for _, r in df.iterrows():
        K = float(r["strike"])
        S0 = float(r["spot"])
        c = float(r["mid"])
        if S_close <= K:
            p = (S_close - S0) + c
            assigned.append(False)
        else:
            p = (K - S0) + c
            assigned.append(True)
        per_share.append(p)
    df["assigned"] = assigned
    df["pnl_per_share"] = per_share
    df["pnl_per_contract"] = df["pnl_per_share"] * 100.0
    out = csv_path.replace(".csv", "_marked.csv")
    df.to_csv(out, index=False)
    return out
    ```


What do I do with this CSV right now?

  • Trade prep: Sort by premium_yield to see efficient candidates. Check the spread (ask - bid) relative to mid; because same-day credits are small, a single tick can make a big difference in fills.
  • Know your numbers: For anything you consider, glance at breakeven and max_profit to confirm you’re happy being called away at that strike and understand your cushion if SPY dips.
  • Build a research trail: Save one CSV after the open and another near the close each trading day. Over a few weeks you’ll have a realistic dataset that reflects what you could actually have traded.


How do I backtest this going forward?

Low-friction, forward collection:

  • Run the screener at the same time each day (e.g., 09:40 ET and 15:55 ET).
  • Archive the CSVs in a folder, and after the close, use mark mode to compute realized P&L.
  • Compare parameter sets (delta bands, OTM ranges, spread guards) by aggregating weekly/monthly results.

Historical reconstruction (deeper):

  • Recreate a similar dataset for past dates using snapshots or historical quotes, then compute the same breakeven, max_profit, and realized results at each close.
  • This lets you compare 0-DTE vs weekly, 30-delta vs ATM, etc., across market regimes.


Profitability heuristics with a concrete example

Formulas are handy, but it clicks when you see the numbers. Suppose your CSV shows:

  • spot (S0) = $444.50
  • strike (K) = $445.00
  • mid premium (c) = $0.75 (remember: 1 contract = 100 shares)

Breakeven — the stock price where the trade neither wins nor loses at expiration:

  • breakeven = S0 − c = 444.50 − 0.75 = 443.75

That means if SPY finishes at $443.75, you break even. You’re cushioned by about $0.75 on the downside compared to owning the stock without the call.

Max profit per share — happens if you’re called away (SPY finishes above the strike):

  • max_profit = (K − S0) + c = (445.00 − 444.50) + 0.75 = 1.25

You keep the premium and capture the move up to the strike, but nothing beyond it. Per contract, that’s $125 before fees.

Two end-of-day scenarios:

  • Stock finishes below strike (e.g., $443.00): P&L per share = (443.00 − 444.50) + 0.75 = −1.50 + 0.75 = −0.75 You lost $0.75 per share (the stock fell $1.50, the premium softened half of it). Per contract = −$75.
  • Stock finishes above strike (e.g., $447.00): P&L per share = (445.00 − 444.50) + 0.75 = 1.25 You get called away at $445, keep the $0.75, and lock in $1.25 per share. Per contract = $125.

Rough probability of profit (POP): if implied volatility (iv) and time to close are known, we can estimate the chance that SPY finishes above breakeven (443.75). For same-day options, this is a quick, direction-agnostic sanity check. Treat it as a ranking helper—not a guarantee—because intraday behavior, spreads, and fills matter in the real world.


Full single-file script

Again, you can find the full code for this demo in this GitHub repository.

  • Commands:
    • screen — pull candidates and save a CSV
    • mark — read a prior CSV and compute realized P&L using the day’s close
  • Configurable knobs: pass flags (no code edits needed)
import os, math, argparse
import pandas as pd
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from pathlib import Path
from polygon import RESTClient
from dotenv import load_dotenv

ET = ZoneInfo("America/New_York")

# Load environment variables from .env file
load_dotenv()

def make_client():
    return RESTClient(api_key=os.getenv("POLYGON_API_KEY"))

def today_et() -> datetime:
    return datetime.now(ET)

def target_expiration_date(days_ahead: int) -> str:
    d = today_et().date() + timedelta(days=days_ahead)
    return d.strftime("%Y-%m-%d")

def minutes_to_close_on(date_str: str) -> float:
    d = datetime.strptime(date_str, "%Y-%m-%d").date()
    close_dt = datetime.combine(d, time(16, 0), tzinfo=ET)
    now = today_et()
    return max(0.0, (close_dt - now).total_seconds() / 60.0)

def time_to_expiry_years(date_str: str) -> float:
    mins = minutes_to_close_on(date_str)
    return max(mins / (60 * 24 * 365), 1e-6)

def fetch_chain_snapshot_calls(client, symbol: str, expiration_date: str):
    items = []
    for o in client.list_snapshot_options_chain(
        symbol,
        params={
            "contract_type": "call",
            "expiration_date.gte": expiration_date,
            "expiration_date.lte": expiration_date,
        },
    ):
        items.append(o)
    return items

def resolve_spot(chain, client, symbol: str) -> float | None:
    for o in chain:
        ua = getattr(o, "underlying_asset", None)
        if ua and getattr(ua, "price", None) is not None:
            return ua.price
    lt = client.get_last_trade(symbol)
    return getattr(lt, "price", None)

def norm_cdf(x: float) -> float:
    return 0.5 * (1.0 + math.erf(x / math.sqrt(2)))

def midpoint(bid: float | None, ask: float | None) -> float | None:
    if bid is None or ask is None or bid <= 0 or ask <= 0 or ask < bid:
        return None
    return 0.5 * (bid + ask)

def pop_estimate(S0: float, breakeven: float, iv: float | None, t_years: float) -> float | None:
    if iv is None or iv <= 0 or breakeven <= 0 or t_years <= 0:
        return None
    d2 = (math.log(S0 / breakeven) - 0.5 * (iv ** 2) * t_years) / (iv * math.sqrt(t_years))
    return norm_cdf(d2)

def screen_candidates(chain,
                      spot: float,
                      expiration_date: str,
                      min_otm_pct=0.00,
                      max_otm_pct=0.03,
                      delta_lo=0.15,
                      delta_hi=0.35,
                      min_bid=0.05,
                      min_oi=1,
                      max_spread_to_mid=0.75,
                      rank_metric="premium_yield"):
    lo = spot * (1 + min_otm_pct)
    hi = spot * (1 + max_otm_pct) if max_otm_pct else float("inf")
    t_years = time_to_expiry_years(expiration_date)

    rows = []
    for o in chain:
        d = getattr(o, "details", None)
        q = getattr(o, "last_quote", None)
        g = getattr(o, "greeks", None)
        oi = getattr(o, "open_interest", 0) or 0
        iv = getattr(o, "implied_volatility", None)

        if not d or not q:
            continue

        k = d.strike_price
        if k is None or not (k >= lo and k <= hi):
            continue

        bid, ask = q.bid, q.ask
        if bid is None or ask is None or bid < min_bid:
            continue

        m = midpoint(bid, ask)
        if m is None or m <= 0:
            continue

        spread = ask - bid
        if m > 0 and (spread / m) > max_spread_to_mid:
            continue

        delta_ok = True
        delta_val = None
        if g and getattr(g, "delta", None) is not None:
            delta_val = abs(g.delta)
            delta_ok = (delta_lo <= delta_val <= delta_hi)
        if not delta_ok:
            continue

        breakeven = spot - m
        max_profit = (k - spot) + m
        pop = pop_estimate(spot, breakeven, iv, t_years)

        rows.append({
            "ticker": d.ticker,
            "expiration": d.expiration_date,
            "strike": k,
            "delta": delta_val,
            "bid": bid,
            "ask": ask,
            "mid": m,
            "open_interest": oi,
            "iv": iv,
            "spot": spot,
            "premium_yield": m / spot,
            "breakeven": breakeven,
            "max_profit": max_profit,
            "pop_est": pop,
        })

    if rank_metric == "premium_yield":
        rows.sort(key=lambda r: r["premium_yield"], reverse=True)
    elif rank_metric == "max_profit":
        rows.sort(key=lambda r: r["max_profit"], reverse=True)
    elif rank_metric == "pop_est":
        rows.sort(key=lambda r: (r["pop_est"] or 0), reverse=True)
    else:
        rows.sort(key=lambda r: r["premium_yield"], reverse=True)

    return rows

def save_csv(rows, outdir: str, symbol: str, expiration_date: str) -> str:
    Path(outdir).mkdir(parents=True, exist_ok=True)
    df = pd.DataFrame(rows)
    path = Path(outdir) / f"{symbol.lower()}_{expiration_date}_0dte_calls.csv"
    df.to_csv(path, index=False)
    return str(path)

def mark_realized_pnl(csv_path: str, underlying_close: float) -> str:
    df = pd.read_csv(csv_path)
    S_close = float(underlying_close)
    per_share = []
    assigned = []
    for _, r in df.iterrows():
        K = float(r["strike"])
        S0 = float(r["spot"])  # Price when you sold the call
        c = float(r["mid"])    # Premium collected
        if S_close <= K:
            # Not assigned: keep the premium collected
            p = c
            assigned.append(False)
        else:
            # Assigned: premium + (strike - spot price when call was sold)
            # This represents the gain from selling at strike vs market price
            p = c + (K - S0)
            assigned.append(True)
        per_share.append(p)
    df["assigned"] = assigned
    df["pnl_per_share"] = per_share
    df["pnl_per_contract"] = df["pnl_per_share"] * 100.0
    out = csv_path.replace(".csv", "_marked.csv")
    df.to_csv(out, index=False)
    return out

def main():
    ap = argparse.ArgumentParser(description="0-DTE covered-call screener")
    sub = ap.add_subparsers(dest="cmd", required=True)

    sc = sub.add_parser("screen", help="Run the screener and write a CSV")
    sc.add_argument("--symbol", default="SPY")
    sc.add_argument("--expiration-days", type=int, default=0)
    sc.add_argument("--min-otm-pct", type=float, default=0.00)
    sc.add_argument("--max-otm-pct", type=float, default=0.03)
    sc.add_argument("--delta-lo", type=float, default=0.15)
    sc.add_argument("--delta-hi", type=float, default=0.35)
    sc.add_argument("--min-bid", type=float, default=0.05)
    sc.add_argument("--min-open-interest", type=int, default=1)
    sc.add_argument("--max-spread-to-mid", type=float, default=0.75)
    sc.add_argument("--rank-metric", choices=["premium_yield", "max_profit", "pop_est"], default="premium_yield")
    sc.add_argument("--outdir", default="./data")

    mk = sub.add_parser("mark", help="Compute realized P&L for a prior CSV")
    mk.add_argument("--csv", required=True)
    mk.add_argument("--underlying-close", required=True, type=float)

    args = ap.parse_args()

    if args.cmd == "screen":
        client = make_client()
        exp = target_expiration_date(args.expiration_days)
        chain = fetch_chain_snapshot_calls(client, args.symbol, exp)
        if not chain:
            raise SystemExit("No contracts returned (holiday/closed market or filters too strict).")
        spot = resolve_spot(chain, client, args.symbol)
        if spot is None:
            raise SystemExit("Could not resolve underlying spot.")
        rows = screen_candidates(
            chain,
            spot,
            exp,
            min_otm_pct=args.min_otm_pct,
            max_otm_pct=args.max_otm_pct,
            delta_lo=args.delta_lo,
            delta_hi=args.delta_hi,
            min_bid=args.min_bid,
            min_oi=args.min_open_interest,
            max_spread_to_mid=args.max_spread_to_mid,
            rank_metric=args.rank_metric,
        )
        if not rows:
            raise SystemExit("No candidates passed the filters.")
        path = save_csv(rows, args.outdir, args.symbol, exp)
        print(f"Wrote {len(rows)} rows to {path}")

    elif args.cmd == "mark":
        out = mark_realized_pnl(args.csv, args.underlying_close)
        print(f"Marked P&L written to {out}")

if __name__ == "__main__":
    main()

Run examples:

# 0-DTE screen, default filters, save to ./data
POLYGON_API_KEY=YOUR_KEY python screener.py screen

# 0-DTE with a wider OTM band and tighter spread filter
POLYGON_API_KEY=YOUR_KEY python screener.py screen --max-otm-pct 0.05 --max-spread-to-mid 0.5

# Mark yesterday's CSV with the official close
python screener.py mark --csv ./data/spy_2025-08-29_0dte_calls.csv --underlying-close 444.83


Advanced Covered Call Screener

In addition to this 0-DTE focused screener, I also wrote an advanced version that has more capabilities and automation. You can find the code for that screener here. Checkout the readme for more detailed instructions.


Disclaimer

This content is for educational purposes only and is not financial advice. Options involve significant risk and are not suitable for all investors. Understand early assignment risk (especially near ex-dividend dates) and test thoroughly before trading real capital.

From the blog

See what's happening at polygon.io

benzingadata partnership Feature Image
announcement

Benzinga Data Now Available on Polygon.io

Polygon.io is excited to announce a new partnership with Benzinga, significantly enhancing our financial data offerings. Benzinga’s detailed analyst ratings, corporate guidance, earnings reports, and structured financial news are now available through Polygon’s REST APIs.

hunting anomalies in the stock market Feature Image
tutorial

Hunting Anomalies in the Stock Market

This tutorial demonstrates how to detect short-lived statistical anomalies in historical US stock market data by building tools to identify unusual trading patterns and visualize them through a user-friendly web interface.