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:
- pull full options chain via ThetaData bulk snapshot (parallel by expiry)
- for each short strike candidate in the target delta range, compute IV percentile rank vs 30-day history
- build condor candidates at all four wing widths
- score each candidate:
(credit / width) * (avg_iv_rank / 50.0) * dte_factor - 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