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.
The snippets here are simplified; check the repo for the exact implementation and latest updates.
What are iron condors?
An iron condor is a four‑leg options strategy built from two credit spreads with the same expiration:
Bull put spread (lower wing)
Sell 1 OTM put (short put)
Buy 1 further‑OTM put (long put)
Bear call spread (upper wing)
Sell 1 OTM call (short call)
Buy 1 further‑OTM call (long call)
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:
Max profit = net credit received (per share) Achieved if the underlying finishes between the short put and short call at expiration, so all options expire worthless.
Max loss = spread width − net credit (per share) If both spreads are the same width, max loss is that width minus the credit.
Breakevens
Lower: short put strike − net credit
Upper: short call strike + net credit
The payoff graph is the classic “flat‑top” shape: a wide profit range between the short strikes, with capped losses outside the wings.
Why trade iron condors?
Iron condors are popular because they’re:
Neutral and range‑bound You’re not betting up or down—you’re betting on no huge move. As long as the underlying finishes in your zone, you keep some or all of the credit.
Defined risk, defined reward The long options cap losses on both sides. You know your worst‑case loss when you enter.
Short volatility + positive theta You’re selling extrinsic value. The trade benefits if implied volatility falls or stays flat and if time passes without a big move.
Highly tunable You can dial:
How far your short strikes sit from spot (via delta)
How wide the wings are (spread width)
Days to expiration (short‑dated vs 30–60 DTE)
How much credit vs. risk you’re comfortable with
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.
What makes a good iron condor?
Every trader’s definition of “good” is different, but our screener is designed around some common constraints:
Liquidity
Minimum open interest and decent quotes on all four legs
Spread‑to‑mid guard so you’re not chasing ghost quotes
Reasonable DTE window
Typically ~25–60 days to expiration (configurable)
Short enough that time decay helps, long enough to collect meaningful credit
Balanced structure around spot
Short put below spot, short call above spot
Short strikes chosen via a target delta or moneyness band
Symmetric or near‑symmetric wings (e.g., $5 wide on each side)
Healthy credit vs. risk
Net credit big enough to justify tail risk
Return on risk (credit ÷ max loss) above a floor you set
A profit zone you actually like
Lower/upper breakevens wide enough that normal daily swings don’t instantly threaten the trade
Prerequisites
In order to run the demo, you’ll need:
Python 3.10+
A Massive API key loaded into your environment as MASSIVE_API_KEY
Number of rows printed in the console (CSV still saves all).
10
10
--outdir
Directory for CSV output.
./data
./data
Authenticate the client
First, we grab our key from an environment variable and build a simple RESTClient to connect to Massive.
import os
from massive import RESTClient
defmake_client() -> RESTClient: api_key = os.getenv("MASSIVE_API_KEY")
ifnot api_key:
raise RuntimeError("Set MASSIVE_API_KEY in your environment.")
return RESTClient(api_key=api_key)
Work in New York time
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")
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")
You can pass --expiration-days 30, and the script converts that into a YYYY‑MM‑DD string to use in your Massive requests.
Pull the option chain snapshot for this expiration
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:
deffetch_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:
details (strike, contract type, expiration, etc.)
last_quote (bid/ask)
greeks (delta, maybe others)
open_interest and implied_volatility
underlying_asset with the current price in many cases
Resolve the underlying price (spot)
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:
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)
If this comes back None, the script can bail early with a clear error.
Build iron condor candidates and compute key numbers
Now the fun part: translating “sell an iron condor around 20‑delta” into explicit rules.
At a high level, we’ll:
Split the chain into puts and calls for the chosen expiration.
Choose candidate short options (put and call) using delta.
Attach long wings a fixed distance away (the wing_width).
Compute credit, max loss, return on risk, and breakevens.
Filter and rank.
A simplified version might look like this:
import math
defmidpoint(bid: float | None, ask: float | None) -> float | None:if bid isNoneor ask isNone:
returnNoneif bid <= 0or ask <= 0or ask < bid:
returnNonereturn0.5 * (bid + ask)
defsplit_chain(chain, expiration_date: str): puts, calls = [], []
for o in chain:
d = getattr(o, "details", None)
ifnot 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
defbuild_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 filtersdefclean(legs, is_put: bool): out = []
for o in legs:
d = o.details
q = o.last_quote
ifgetattr(o, "open_interest", 0) < min_open_interest:
continue m = midpoint(q.bid, q.ask)
if m isNoneor q.bid <= 0:
continue spread = q.ask - q.bid
if m > 0and spread / m > max_spread_to_mid:
continue# only OTM k = d.strike_price
if is_put and k >= spot:
continueifnot 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 deltadefchoose_short(legs, target_delta: float, is_put: bool): candidates = []
for o in legs:
g = getattr(o, "greeks", None)
ifnot g or g.delta isNone:
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)
ifnot 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 isNoneor lp_mid isNone:
continuefor 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)
ifnot 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 isNoneor lc_mid isNone:
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.
Save to CSV
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.
Sort by return_on_risk or credit to see which condors are paying the most per unit of risk.
Filter out any rows with breakevens you’re not comfortable with.
Sanity‑check
For any candidate you’re serious about, pull up the exact four‑leg structure in your trading platform to confirm quotes, buying power impact, and assignment behavior.
Research trail
Save one CSV per symbol per day (or per run).
Over a few weeks or months, you’ll have real‑world data reflecting what you could have traded, not just theoretical prices.
How do I backtest this going forward?
The screener has a built-in P&L option. You could do something like the following:
Run the screener at the same time each trading day (e.g., 10:00 ET or 15:30 ET).
Save CSVs into a dated folder structure.
Later, run the pnl command like this.
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.
Profitability heuristics with a concrete example
Formulas are one thing; seeing numbers is where it clicks. Suppose the screener shows an SPY condor like this:
Underlying price when you screen, S0 = 440
Put side:
Short put at 430
Long put at 425
Call side:
Short call at 450
Long call at 455
Net credit from all four legs = 2.00 (per share)
Spread width
Put spread width = 430 − 425 = 5
Call spread width = 455 − 450 = 5
We’ll assume symmetric 5‑wide wings:
width = 5
Max profit per share
max_profit = credit = 2.00
Per contract: 2.00 × 100 = $200
You realize this if SPY finishes between the short strikes (430–450) at expiration and all options expire worthless.
Max loss per share
max_loss = width − credit = 5 − 2 = 3.00
Per contract: 3.00 × 100 = $300
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.
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.
Running the script
Again, you can find the full code for this example here.
Putting it all together, a typical run might look like:
Clone the repo and cd into the example
git clone https://github.com/massive-com/community.git
cd community/examples/rest/options-iron-condor
Copy the .env file and set your Massive API key
export MASSIVE_API_KEY=YOUR_KEY_HERE
Screen for SPY iron condors up to 30 days into the future
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.
Disclaimer
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.
In this post, we'll walk through how Earnings Watcher built its analytics layer on Massive's options chains and snapshots, why Fair Market Value anchors its scanner, and how the same API pattern extended from options into post-earnings drift analysis on the underlying equity.
Massive’s Futures APIs are now generally available, providing real-time and historical market data for top U.S. futures (ES, GC, CL) from CME, CBOT, COMEX, and NYMEX, accessible via REST API, Websocket, and flat files.