Skip to main content

may cycle setup: scanning the iv surface, automating strike selection

2:30 AM. wednesday.

april is basically wrapped. last weekly expiration cleared friday. monday was flat, tuesday had one small SPX position that ticked through on delta and I let it ride — closed today for +$1,100. running estimate: april MTD somewhere around +$16,500 when everything settles. YTD is going to land around +1.5%.

not going to write much about april. wrote it up friday. what I want to write about is what happens next: setting up may.


why you can’t pick strikes manually at scale
#

in 2024, when the condor book was small, I picked strikes by eyeballing the options chain. delta 0.15-ish on both sides, decent credit, call it a day. worked okay when I was running two positions at once.

that doesn’t scale. right now the scanner is evaluating:

  • 3 symbols (SPX, QQQ, XLF)
  • 4 expirations per symbol (may 15, may 29, jun 19, jul 17)
  • 4 wing widths per expiration/symbol combo ($5, $10, $15, $20 or point equivalents)

that’s 48 combinations before I’ve even applied any filters. and the IV surface changes intraday. if I’m checking at 10:30 AM and entering at 2 PM, the rank I computed by hand is already wrong.

so the scanner handles it. core logic:

  1. pull full options chain via ThetaData bulk snapshot (parallel by expiry)
  2. for each short strike candidate in the target delta range, compute IV percentile rank vs 30-day history
  3. build condor candidates at all four wing widths
  4. score each candidate: (credit / width) * (avg_iv_rank / 50.0) * dte_factor
  5. top 10 go into Redis. I pull them whenever I want without touching the colo.

the scanner
#

this is the current version. the scoring function is where most of the thinking lives — reducing a 2D decision space (credit efficiency + IV environment) to a single number I can sort on.

from __future__ import annotations

import asyncio
import aiohttp
import json
import logging
import numpy as np

from dataclasses import dataclass
from datetime import date, timedelta
from typing import Optional

import redis.asyncio as aioredis

logger = logging.getLogger(__name__)


@dataclass
class OptionLeg:
    symbol: str
    expiry: date
    strike: float
    option_type: str   # 'C' or 'P'
    bid: float
    ask: float
    delta: float
    iv: float          # annualized implied vol (e.g. 0.18 = 18%)
    iv_rank: float     # percentile rank vs 30-day history (0-100)

    @property
    def mid(self) -> float:
        return (self.bid + self.ask) / 2.0


@dataclass
class IronCondorCandidate:
    symbol: str
    expiry: date
    put_short: OptionLeg
    put_long: OptionLeg
    call_short: OptionLeg
    call_long: OptionLeg

    @property
    def dte(self) -> int:
        return (self.expiry - date.today()).days

    @property
    def net_credit(self) -> float:
        return (
            self.put_short.mid - self.put_long.mid
            + self.call_short.mid - self.call_long.mid
        )

    @property
    def wing_width(self) -> float:
        return self.call_long.strike - self.call_short.strike

    @property
    def credit_ratio(self) -> float:
        if self.wing_width <= 0:
            return 0.0
        return self.net_credit / self.wing_width

    @property
    def avg_iv_rank(self) -> float:
        return np.mean([self.put_short.iv_rank, self.call_short.iv_rank])

    @property
    def pop_estimate(self) -> float:
        """
        rough probability of profit from short leg deltas.
        for delta 0.15 short put and 0.15 short call:
        POP ≈ 1 - |put_delta| - |call_delta|
        """
        return 1.0 - abs(self.put_short.delta) - abs(self.call_short.delta)

    @property
    def score(self) -> float:
        """composite score: higher = better candidate.

        - credit_ratio: how much you're getting paid per dollar of risk
        - avg_iv_rank / 50: normalized iv rank (1.0 = median, 2.0 = max)
        - dte_factor: preference for 21-35 DTE theta acceleration zone
        """
        dte_factor = 1.0
        if 21 <= self.dte <= 35:
            dte_factor = 1.15
        elif self.dte < 14:
            dte_factor = 0.70   # gamma risk spikes below 2 weeks
        elif self.dte > 45:
            dte_factor = 0.85   # capital tied up too long
        return self.credit_ratio * (self.avg_iv_rank / 50.0) * dte_factor


class ThetaDataClient:
    """thin wrapper around ThetaData's local proxy running in colo"""

    BASE_URL = "http://localhost:25510"

    def __init__(self, session: aiohttp.ClientSession):
        self.session = session

    async def get_chain(self, symbol: str, expiry: date) -> list[dict]:
        params = {
            "root": symbol,
            "exp": expiry.strftime("%Y%m%d"),
        }
        async with self.session.get(
            f"{self.BASE_URL}/v2/bulk_snapshot/option/greeks",
            params=params,
        ) as resp:
            resp.raise_for_status()
            return (await resp.json()).get("response", [])

    async def get_iv_rank(
        self,
        symbol: str,
        expiry: date,
        strike: float,
        right: str,
        lookback: int = 30,
    ) -> float:
        end = date.today()
        start = end - timedelta(days=lookback)
        params = {
            "root": symbol,
            "exp": expiry.strftime("%Y%m%d"),
            "strike": int(strike * 1000),
            "right": right[0].upper(),
            "start_date": start.strftime("%Y%m%d"),
            "end_date": end.strftime("%Y%m%d"),
        }
        async with self.session.get(
            f"{self.BASE_URL}/v2/hist/option/greeks",
            params=params,
        ) as resp:
            if resp.status != 200:
                return 50.0
            hist = (await resp.json()).get("response", [])

        iv_series = [r["iv"] for r in hist if r.get("iv") is not None]
        if len(iv_series) < 5:
            return 50.0

        current = iv_series[-1]
        rank = np.searchsorted(sorted(iv_series), current) / len(iv_series)
        return round(rank * 100, 1)


class IronCondorScanner:
    def __init__(
        self,
        redis_client: aioredis.Redis,
        td: ThetaDataClient,
        symbols: list[str],
        expirations: list[date],
        delta_range: tuple[float, float] = (0.12, 0.20),
        min_credit_ratio: float = 0.14,
        min_iv_rank: float = 28.0,
        wing_widths: list[float] = None,
    ):
        self.redis = redis_client
        self.td = td
        self.symbols = symbols
        self.expirations = expirations
        self.delta_range = delta_range
        self.min_credit_ratio = min_credit_ratio
        self.min_iv_rank = min_iv_rank
        self.wing_widths = wing_widths or [5.0, 10.0, 15.0, 20.0]

    def _closest_leg(
        self, chain: list[dict], right: str, strike: float
    ) -> Optional[dict]:
        legs = [o for o in chain if o.get("right", "").upper() == right.upper()]
        if not legs:
            return None
        return min(legs, key=lambda o: abs(o.get("strike", 9999) - strike))

    async def scan_expiry(
        self, symbol: str, expiry: date
    ) -> list[IronCondorCandidate]:
        chain = await self.td.get_chain(symbol, expiry)
        if not chain:
            return []

        lo, hi = self.delta_range
        puts = [
            o for o in chain
            if o.get("right") == "P" and lo <= abs(o.get("delta", 0)) <= hi
        ]
        calls = [
            o for o in chain
            if o.get("right") == "C" and lo <= abs(o.get("delta", 0)) <= hi
        ]
        if not puts or not calls:
            return []

        short_put = max(puts, key=lambda o: o.get("ask", 0))
        short_call = max(calls, key=lambda o: o.get("ask", 0))

        # parallel IV rank fetches for both short legs
        put_rank, call_rank = await asyncio.gather(
            self.td.get_iv_rank(symbol, expiry, short_put["strike"], "P"),
            self.td.get_iv_rank(symbol, expiry, short_call["strike"], "C"),
        )

        def make_leg(raw: dict, rank: float) -> OptionLeg:
            return OptionLeg(
                symbol=symbol,
                expiry=expiry,
                strike=raw["strike"],
                option_type=raw["right"],
                bid=raw.get("bid", 0),
                ask=raw.get("ask", 0),
                delta=raw.get("delta", 0),
                iv=raw.get("iv", 0),
                iv_rank=rank,
            )

        candidates = []
        for width in self.wing_widths:
            pl_raw = self._closest_leg(chain, "P", short_put["strike"] - width)
            cl_raw = self._closest_leg(chain, "C", short_call["strike"] + width)
            if pl_raw is None or cl_raw is None:
                continue

            c = IronCondorCandidate(
                symbol=symbol,
                expiry=expiry,
                put_short=make_leg(short_put, put_rank),
                put_long=make_leg(pl_raw, 50.0),
                call_short=make_leg(short_call, call_rank),
                call_long=make_leg(cl_raw, 50.0),
            )
            if c.credit_ratio >= self.min_credit_ratio and c.avg_iv_rank >= self.min_iv_rank:
                candidates.append(c)

        return sorted(candidates, key=lambda x: x.score, reverse=True)

    async def run(self) -> list[IronCondorCandidate]:
        tasks = [
            self.scan_expiry(sym, exp)
            for sym in self.symbols
            for exp in self.expirations
        ]
        results = await asyncio.gather(*tasks)
        flat = sorted(
            [c for group in results for c in group],
            key=lambda x: x.score,
            reverse=True,
        )

        # cache top 20 in Redis, 30-minute TTL
        payload = [
            {
                "symbol": c.symbol,
                "expiry": c.expiry.isoformat(),
                "dte": c.dte,
                "net_credit": round(c.net_credit, 2),
                "wing_width": c.wing_width,
                "credit_ratio": round(c.credit_ratio, 4),
                "pop": round(c.pop_estimate, 3),
                "avg_iv_rank": round(c.avg_iv_rank, 1),
                "score": round(c.score, 4),
            }
            for c in flat[:20]
        ]
        await self.redis.setex(
            "scanner:condor_candidates:latest", 1800, json.dumps(payload)
        )
        logger.info(f"scan complete: {len(flat)} candidates, top score {flat[0].score:.4f}" if flat else "no candidates")
        return flat

the score property is doing the heavy lifting. highest-scored candidate as of the 11 PM scan tonight: SPX may 15 condor, $20 wings, credit ratio 19.3%, IV rank 44, score 0.243. that’s the one I’m probably opening tomorrow.

the scanner doesn’t decide for me. it gives me a ranked list and I check the calendar. FOMC is may 7. that’s inside the may 15 window. that matters — I’m putting the event risk throttle flag on any may 15 positions and setting max position size at 4 contracts until we’re through the rate decision.


iv surface heading into may
#

here’s what the IV rank surface looks like across the four expirations as of tonight’s 11 PM scan. the color shows IV percentile rank — darker means current IV is high relative to the past 30 days, which means richer premium for selling.

the amber cluster in the upper left is where I want to be selling. -12Δ to -15Δ puts in the may 15 cycle are sitting at IV rank 45-52 — vol is still elevated from the tariff shock and normalizing slowly. that’s the zone where credit ratios are richest and I’m getting paid more per dollar of max loss than the longer-dated cycles.

the gradient going right shows how vol is lower in the back months. nothing wrong with selling jun or jul, but the credit ratio drops and capital sits tied up longer. for the premium selling model I’m running, the sweet spot stays in the 17-35 DTE window.


the candidates
#

here’s the top candidate cluster from tonight’s scan plotted by probability of profit vs credit/width ratio. each point is one condor configuration (different symbol, expiry, or wing width).

the top-right cluster is where I want to be: higher POP, higher credit. the green-orange coloring on those points tells me IV rank is 40-44 range — selling into a vol environment that’s elevated but not panicking.

the two outlier points (lower left) are the XLF and longer-dated QQQ candidates. lower POP, lower credit. not worthless — XLF in particular has some event exposure I’m watching — but they’re not the first executions tomorrow.

the NexusFi community on the options on futures thread has had a long-running debate about whether IV rank or absolute IV level matters more for strike selection. the thread’s been going since 2011, still active. short answer from what I’ve read: rank matters more for consistency, absolute level matters if you’re comparing across underlyings. the scanner uses rank for the within-symbol score and I compare absolute IV when deciding SPX vs QQQ vs XLF allocation.


infrastructure note: data freshness is the whole thing
#

the scanner runs in chicago on the colo box. ThetaData’s options API endpoint is hosted on infrastructure close to CME — round trip for the bulk snapshot call is 4.2ms from chicago, versus 28ms from my san diego machine.

that gap only matters because I’m computing IV rank in real-time during the scan. if the chain data is stale by 30 seconds during fast tape, the IV rank can shift 10-15 percentile points. that’s enough to misclassify a trade. “high IV environment, sell” vs “IV collapsed, wait” is a binary decision that matters a lot.

the cache architecture handles the afternoon latency problem: scanner runs in chicago every 30 minutes, pushes to Redis with 30-minute TTL, I query Redis from san diego all evening without touching the live API. data is at most 29 minutes stale when I’m reviewing at midnight. acceptable for my decision cadence.

colo total: $680/month. Redis on the same box. ThetaData access through their endpoint. for what I get out of it, still the most efficient line item in the budget.


tomorrow’s plan
#

opening SPX may 15 condors first. $20 wings, 4 contracts max (FOMC event risk inside the window). setting the event throttle at delta 0.08 — if SPX moves more than 1% intraday and delta touches 0.08 on either short leg, auto-roll the threatened side.

QQQ may 15 afterward if the morning opens clean. 2-3 contracts.

XLF I’m watching but not touching until after may 7 rate decision. sector ETFs move weird around FOMC.

NQ adaptive lookback is now in week two of live trading. no additional changes yet — the point of week two is to let it run and collect data, not tinker.

been at the desk since 9 PM. A. left pasta in the fridge before she went to bed. it’s 2:30 and I still haven’t touched it. probably should.


one thing hit tonight while I was debugging the _closest_leg edge case — there’s a specific kind of satisfaction in watching your own tooling work cleanly. the scanner ran at 11 PM, 12 candidates in Redis in 8 seconds, ranked correctly, top pick exactly what I expected it to be.

dad built software for a living. different domain. but I think he’d recognize the thing I’m describing — when a system you designed does what you designed it to do, quietly, without you touching it.

been over three years. that one still lands sometimes.

close the notebook. alarm at 8:45.

-AK

Related

april theta harvest: weekly closed clean, colo queue backed up, thursday hit different
2:30 AM. friday night. A. made chicken marsala — she does it maybe once a month and I forget every time how good it is. ate around 7, she went back to her desk, lights off in the bedroom by midnight. apartment’s quiet. been staring at P&L since 11.
fixing the stale iv problem: thetadata websocket streaming for real-time greeks
2:30 AM friday. been at this since 9 PM. promised myself two weeks ago, right in the middle of the tariff chaos, that i’d actually fix the IV rank staleness issue. the signal quality scoring work was the band-aid — a composite gate that tells the system “this signal isn’t reliable right now.” it worked. it’s in production. but the underlying problem was unchanged: during the spike, my IV rank was being computed from options data that was 10-14 minutes old. the signal wasn’t wrong, technically. it was just answering a question about a market that no longer existed.
signal quality scoring: building a market-aware trade gate
2:15 AM wednesday. apartment quiet. A. went to bed around midnight — she had a client deadline today so it was a long one. checked the colo heartbeat before sitting down to write this. normal. algos running clean for the first time since last monday.
tariff week post-mortem: what the data actually showed
2:30 AM monday. week one of what i’m calling “the post-tariff-chaos era” starts in a few hours. last week was one of those that splits into a clear before and after. monday and tuesday felt like freefall — VIX went from 20 to 32 in about 36 hours, SPX dropped hard, options spreads blew out 3-4x, and my event risk throttle (which I built the week prior and wrote about here) was earning every line of code it took to build. then wednesday happened. whoever made the tariff pause call did it at 1:07 PM eastern and watching the S&P rip 8% in ninety minutes while running algorithms was… a lot.
options contract lifecycle: building the roll engine and pin risk detector
2:30 AM wednesday. A. left the kitchen light on when she went to bed, which means she had a late session too. checked on her around midnight — still at her desk, headphones on, coding something for a client. now she’s asleep and I’m at mine.
replaying the yen carry unwind: validating sqs against a real vol event
2:15 AM monday. system’s been clean since the websocket IV fix went live friday. heartbeat healthy, colo latency normal, no stale data flags. spent most of sunday going deep on something i’ve been meaning to do since the tariff postmortem.