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.
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.
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
defmake_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")
deftoday_et() -> datetime:return datetime.now(ET)
deftarget_expiration_date(days_ahead: int) -> str: d = today_et().date() + timedelta(days=days_ahead)
return d.strftime("%Y-%m-%d")
defminutes_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()
returnmax(0.0, (close_dt - now).total_seconds() / 60.0)
deftime_to_expiry_years(date_str: str) -> float: mins = minutes_to_close_on(date_str)
returnmax(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.
deffetch_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.
defresolve_spot(chain, client, symbol: str) -> float | None:for o in chain:
ua = getattr(o, "underlying_asset", None)
if ua andgetattr(ua, "price", None) isnotNone:
return ua.price
lt = client.get_last_trade(symbol)
returngetattr(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
defnorm_cdf(x: float) -> float:return0.5 * (1.0 + math.erf(x / math.sqrt(2)))
defmidpoint(bid: float | None, ask: float | None) -> float | None:if bid isNoneor ask isNoneor bid <= 0or ask <= 0or ask < bid:
returnNonereturn0.5 * (bid + ask)
defpop_estimate(S0: float, breakeven: float, iv: float | None, t_years: float) -> float | None:if iv isNoneor iv <= 0or breakeven <= 0or t_years <= 0:
returnNone# 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)
defscreen_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 elsefloat("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) or0 iv = getattr(o, "implied_volatility", None)
ifnot d ornot q:
continue
k = d.strike_price
if k isNoneornot (k >= lo and k <= hi):
continue
bid, ask = q.bid, q.ask
if bid isNoneor ask isNoneor bid < min_bid:
continue
m = midpoint(bid, ask)
if m isNoneor m <= 0:
continue
spread = ask - bid
if m > 0and (spread / m) > max_spread_to_mid:
continue delta_ok = True delta_val = Noneif g andgetattr(g, "delta", None) isnotNone:
delta_val = abs(g.delta)
delta_ok = (delta_lo <= delta_val <= delta_hi)
ifnot 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 metricif 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"] or0), 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.
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)
defmark_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.
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:
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 fileload_dotenv()
defmake_client():return RESTClient(api_key=os.getenv("POLYGON_API_KEY"))
deftoday_et() -> datetime:return datetime.now(ET)
deftarget_expiration_date(days_ahead: int) -> str: d = today_et().date() + timedelta(days=days_ahead)
return d.strftime("%Y-%m-%d")
defminutes_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()
returnmax(0.0, (close_dt - now).total_seconds() / 60.0)
deftime_to_expiry_years(date_str: str) -> float: mins = minutes_to_close_on(date_str)
returnmax(mins / (60 * 24 * 365), 1e-6)
deffetch_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
defresolve_spot(chain, client, symbol: str) -> float | None:for o in chain:
ua = getattr(o, "underlying_asset", None)
if ua andgetattr(ua, "price", None) isnotNone:
return ua.price
lt = client.get_last_trade(symbol)
returngetattr(lt, "price", None)
defnorm_cdf(x: float) -> float:return0.5 * (1.0 + math.erf(x / math.sqrt(2)))
defmidpoint(bid: float | None, ask: float | None) -> float | None:if bid isNoneor ask isNoneor bid <= 0or ask <= 0or ask < bid:
returnNonereturn0.5 * (bid + ask)
defpop_estimate(S0: float, breakeven: float, iv: float | None, t_years: float) -> float | None:if iv isNoneor iv <= 0or breakeven <= 0or t_years <= 0:
returnNone d2 = (math.log(S0 / breakeven) - 0.5 * (iv ** 2) * t_years) / (iv * math.sqrt(t_years))
return norm_cdf(d2)
defscreen_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 elsefloat("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) or0 iv = getattr(o, "implied_volatility", None)
ifnot d ornot q:
continue
k = d.strike_price
if k isNoneornot (k >= lo and k <= hi):
continue
bid, ask = q.bid, q.ask
if bid isNoneor ask isNoneor bid < min_bid:
continue
m = midpoint(bid, ask)
if m isNoneor m <= 0:
continue
spread = ask - bid
if m > 0and (spread / m) > max_spread_to_mid:
continue delta_ok = True delta_val = Noneif g andgetattr(g, "delta", None) isnotNone:
delta_val = abs(g.delta)
delta_ok = (delta_lo <= delta_val <= delta_hi)
ifnot 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"] or0), reverse=True)
else:
rows.sort(key=lambda r: r["premium_yield"], reverse=True)
return rows
defsave_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)
returnstr(path)
defmark_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 collectedif 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
defmain(): 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)
ifnot chain:
raise SystemExit("No contracts returned (holiday/closed market or filters too strict).")
spot = resolve_spot(chain, client, args.symbol)
if spot isNone:
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,
)
ifnot 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 ./dataPOLYGON_API_KEY=YOUR_KEY python screener.py screen
# 0-DTE with a wider OTM band and tighter spread filterPOLYGON_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 closepython 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.
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.
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.