Introducing

Build an Iron Condor Screener with Massive

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.


What this demo is

A reusable iron‑condor screener that:

  • Pulls the option chain snapshot for a given symbol and expiration
  • Filters to liquid puts and calls with usable quotes
  • Builds iron condors by pairing:
    • A put spread below spot
    • A call spread above spot
  • Computes per‑condor metrics:
    • Net credit
    • Max profit & max loss (per share / per contract)
    • Return on risk (credit ÷ max loss)
    • Lower & upper breakevens
    • A lightweight probability‑of‑profit estimate (POP)
  • Ranks and saves to CSV so you can:
    • Sort by return on risk or POP
    • Annotate in a spreadsheet
    • Archive files for later backtesting

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.


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
  • Massive Options Advanced plan (for options snapshots & greeks)
  • Basic familiarity with:
    • Options chains, strikes, calls/puts
    • Running Python scripts from the command line


Strategy settings 

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


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


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")
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.


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:

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:

  • 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:

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.


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:

  1. Split the chain into puts and calls for the chosen expiration.
  2. Choose candidate short options (put and call) using delta.
  3. Attach long wings a fixed distance away (the wing_width).
  4. Compute credit, max loss, return on risk, and breakevens.
  5. Filter and rank.

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.


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.

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)


What to do with the CSV

Some quick ways to use the output:

  • Trade prep
    • 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. 

Return on risk

  • return_on_risk = max_profit / max_loss = 2 / 3 ≈ 0.67

So you’re collecting about 67 cents for every dollar of max risk if you hold to expiration.

Breakevens

  • Lower breakeven ≈ short put − credit = 430 − 2 = 428
  • Upper breakeven ≈ short call + credit = 450 + 2 = 452

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:

  1. Clone the repo and cd into the example
git clone https://github.com/massive-com/community.git
cd community/examples/rest/options-iron-condor
  1. Copy the .env file and set your Massive API key
export MASSIVE_API_KEY=YOUR_KEY_HERE
  1. 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.

From the blog

See what's happening at Massive

polygon is now massive Feature Image
announcement

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.

Justin

editor