
Polygon.io is Now Massive
Polygon.io is now Massive.com. The rebrand reflects our focus on scale, reliability, and continued innovation. Your APIs, accounts, and integrations continue to work without interruption.
editor

Introducing
Dec 9, 2025
In this demo, we’ll showcase a small Python utility that screens for iron condors on SPY (or any highly liquid symbol) using Massive’s options API.
Who this is for: developers comfortable with Python who want to automate an options workflow, even if you’re still getting comfortable with multi‑leg strategies.
You must have a Massive Options Advanced plan for this demo to work properly.
A reusable iron‑condor screener that:
You can find the full code for this demo in the Massive community repo.
The snippets here are simplified; check the repo for the exact implementation and latest updates.

An iron condor is a four‑leg options strategy built from two credit spreads with the same expiration:
All four options share the same expiration, and all four strikes are different. The two short options form the body; the two long options form the wings. The position starts as a net credit, meaning you collect a premium up front.
For an iron condor:
The payoff graph is the classic “flat‑top” shape: a wide profit range between the short strikes, with capped losses outside the wings.
Iron condors are popular because they’re:
They’re a natural fit for liquid ETFs and indices (SPY, QQQ, IWM, SPX) when implied volatility is elevated and you expect choppy, range‑bound action rather than massive trends.
Every trader’s definition of “good” is different, but our screener is designed around some common constraints:
In order to run the demo, you’ll need:
The iron‑condor demo exposes a few knobs via CLI flags that can be used to tune what you are searching for. Here are all of the options available:
Flag | Meaning | Default | Good starting point |
--symbol | Underlying ticker (required) | _none_ | SPY |
--min-days / --max-days | DTE window. Only expirations inside this band are considered. | 5 / 7 | --min-days 30 --max-days 45 |
--min-credit | Minimum net credit per condor (per share). | 0.10 | 1.00 |
--max-risk | Maximum allowed max loss per condor (per share). | 10.00 | 5.00 |
--min-probability | Minimum probability-of-profit %. | 30.0 | 40 |
--min-credit-ratio | Minimum credit ÷ spread width (return on risk). | 0.0 | 0.30 |
--min-vol | Minimum daily option volume per leg. | 5 | 100 |
--min-oi | Minimum open interest per leg. | 25 | 200 |
--short-delta-range | Delta band (min,max) for short legs (negative values for puts). | off | --short-delta-range -0.25,0.25 |
--long-delta-range | Delta band for long legs (controls wing position). | off | --long-delta-range -0.10,0.10 |
--short-theta-range / --long-theta-range | Optional theta guards for short/long legs. | off | --short-theta-range -0.40,-0.05 / --long-theta-range -0.15,-0.001 |
--iv-range | Implied-volatility band (min,max). | off | --iv-range 0.15,0.40 |
--account-size | Enables capital guardrail (percent of account). | off | 100000 |
--max-capital-pct | Max percent of account per condor (used when --account-size set). | 5.0 | 3 |
--max-capital | Absolute USD cap per condor (per contract risk). | off | 2500 (optional if you prefer a hard dollar cap) |
--criteria | Console ranking metric (credit, probability, risk_reward). | credit | risk_reward |
--limit | Number of rows printed in the console (CSV still saves all). | 10 | 10 |
--outdir | Directory for CSV output. | ./data | ./data |
First, we grab our key from an environment variable and build a simple RESTClient to connect to Massive.
import os from massive import RESTClient def make_client() -> RESTClient: api_key = os.getenv("MASSIVE_API_KEY") if not api_key: raise RuntimeError("Set MASSIVE_API_KEY in your environment.") return RESTClient(api_key=api_key)
When you say “30 DTE,” you usually mean “30 calendar days on the exchange calendar,” not “30 days from my laptop’s timezone.”
To avoid drift, we standardize to America/New_York:
from datetime import datetime, 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")
You can pass --expiration-days 30, and the script converts that into a YYYY‑MM‑DD string to use in your Massive requests.
The iron‑condor screener needs both puts and calls for the same expiration. We’ll use Massive’s snapshot endpoint to grab everything in one pass per side:
def fetch_chain_snapshot(client, symbol: str, expiration_date: str): items = [] for side in ("put", "call"): for o in client.list_snapshot_options_chain( symbol, params={ "contract_type": side, "expiration_date.gte": expiration_date, "expiration_date.lte": expiration_date, }, ): items.append(o) return items
Each o will have:
Almost every decision—OTM, breakevens, POP estimates—depends on the underlying price.
We’ll use the snapshot if it’s there, otherwise fall back to a last‑trade lookup:
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)
If this comes back None, the script can bail early with a clear error.
Now the fun part: translating “sell an iron condor around 20‑delta” into explicit rules.
At a high level, we’ll:
A simplified version might look like this:
import math def midpoint(bid: float | None, ask: float | None) -> float | None: if bid is None or ask is None: return None if bid <= 0 or ask <= 0 or ask < bid: return None return 0.5 * (bid + ask) def split_chain(chain, expiration_date: str): puts, calls = [], [] for o in chain: d = getattr(o, "details", None) if not d or d.expiration_date != expiration_date: continue ct = d.contract_type.lower() if ct == "put": puts.append(o) elif ct == "call": calls.append(o) return puts, calls def build_iron_condors(chain, spot: float, expiration_date: str, target_put_delta=0.20, target_call_delta=0.20, wing_width=5.0, min_credit=0.50, min_open_interest=50, max_spread_to_mid=0.75): puts, calls = split_chain(chain, expiration_date) # basic quality filters def clean(legs, is_put: bool): out = [] for o in legs: d = o.details q = o.last_quote if getattr(o, "open_interest", 0) < min_open_interest: continue m = midpoint(q.bid, q.ask) if m is None or q.bid <= 0: continue spread = q.ask - q.bid if m > 0 and spread / m > max_spread_to_mid: continue # only OTM k = d.strike_price if is_put and k >= spot: continue if not is_put and k <= spot: continue out.append(o) return out puts_clean = clean(puts, is_put=True) calls_clean = clean(calls, is_put=False) # helper to find short legs near a target delta def choose_short(legs, target_delta: float, is_put: bool): candidates = [] for o in legs: g = getattr(o, "greeks", None) if not g or g.delta is None: continue d = abs(g.delta) # basic "close enough" window; tighten if you like diff = abs(d - target_delta) candidates.append((diff, o)) candidates.sort(key=lambda x: x[0]) return [o for _, o in candidates] short_puts = choose_short(puts_clean, target_put_delta, is_put=True) short_calls = choose_short(calls_clean, target_call_delta, is_put=False) rows = [] for sp in short_puts: sp_k = sp.details.strike_price # long put wing lp_k = sp_k - wing_width lp = next((p for p in puts_clean if math.isclose(p.details.strike_price, lp_k, rel_tol=0, abs_tol=1e-6)), None) if not lp: continue sp_mid = midpoint(sp.last_quote.bid, sp.last_quote.ask) lp_mid = midpoint(lp.last_quote.bid, lp.last_quote.ask) if sp_mid is None or lp_mid is None: continue for sc in short_calls: sc_k = sc.details.strike_price lc_k = sc_k + wing_width lc = next((c for c in calls_clean if math.isclose(c.details.strike_price, lc_k, rel_tol=0, abs_tol=1e-6)), None) if not lc: continue sc_mid = midpoint(sc.last_quote.bid, sc.last_quote.ask) lc_mid = midpoint(lc.last_quote.bid, lc.last_quote.ask) if sc_mid is None or lc_mid is None: continue credit = (sp_mid - lp_mid) + (sc_mid - lc_mid) if credit < min_credit: continue width_put = sp_k - lp_k width_call = lc_k - sc_k width = min(width_put, width_call) max_loss = max(width - credit, 0) if max_loss <= 0: continue lower_be = sp_k - credit upper_be = sc_k + credit rows.append({ "symbol": sp.details.underlying_symbol, "expiration": expiration_date, "short_put": sp_k, "long_put": lp_k, "short_call": sc_k, "long_call": lc_k, "credit": round(credit, 2), "max_loss": round(max_loss, 2), "return_on_risk": round(credit / max_loss, 3), "lower_be": round(lower_be, 2), "upper_be": round(upper_be, 2), }) # sort by return on risk by default rows.sort(key=lambda r: r["return_on_risk"], reverse=True) return rows
The code in this blog is a bit more minimal; the real screener.py in the repo includes more careful POP estimators and CLI plumbing.
CSV plays nicely with everything—spreadsheets, notebooks, dashboards, even cron‑based backtests. 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}_iron_condors.csv" df.to_csv(path, index=False) return str(path)
Some quick ways to use the output:
The screener has a built-in P&L option. You could do something like the following:
uv run screener.py pnl --csv data/spy_iron_condors.csv
The `pnl` command reads every row, fetches official close from Massive (or use `--closing-price` to override), and spits out realized P&L, win rate, and average P&L.
Formulas are one thing; seeing numbers is where it clicks. Suppose the screener shows an SPY condor like this:
Spread width
We’ll assume symmetric 5‑wide wings:
Max profit per share
You realize this if SPY finishes between the short strikes (430–450) at expiration and all options expire worthless.
Max loss per share
This happens if SPY finishes below 425 or above 455 at expiration; one of the spreads will be fully in the money and the other mostly or entirely worthless.
Return on risk
So you’re collecting about 67 cents for every dollar of max risk if you hold to expiration.
Breakevens
As long as SPY finishes between 428 and 452 at expiration, the trade finishes profitable; the closer to the center, the closer you are to max profit.
In the CSV you can include these breakevens and return‑on‑risk directly, so you don’t have to redo this math by hand every time.
Again, you can find the full code for this example here.
Putting it all together, a typical run might look like:
git clone https://github.com/massive-com/community.git cd community/examples/rest/options-iron-condor
export MASSIVE_API_KEY=YOUR_KEY_HERE
uv run screener.py screen --symbol SPY --expiration-days 30
When the run completes, you’ll see something like:
Scanned 800 option contracts, constructed 42 iron condors.
Wrote data to ./data/spy_2025-10-17_iron_condors.csv
Open that file to review the candidates. You will also find the top ten candidates in the terminal window.
This content and the associated code are for educational purposes only and are not financial advice. Options—especially multi‑leg strategies like iron condors—carry significant risk and are not suitable for all investors.
Thanks for checking out this demo and tutorial on iron condors. If you are into options trading or what to learn about other strategies, make sure to look at our other options demo for 0-DTE covered calls.
And, until next time, keep building something…Massive.

Alex Novotny
alexnovotny
See what's happening at Massive

Polygon.io is now Massive.com. The rebrand reflects our focus on scale, reliability, and continued innovation. Your APIs, accounts, and integrations continue to work without interruption.
editor

Effective Nov 3, 2025, bid_size/ask_size will be reported in shares (not round lots) across Stocks Quotes REST API, WebSocket, and Flat Files, per SEC MDI rules. The rule is forward-looking and we’ll also backfill history for consistency. Most users need no changes.
editor

Learn how to use Massive's MCP server inside of a Pydantic AI agentic workflow, alongside Anthropic's Claude 4 and the Rich Python library.

alexnovotny