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.
today was SPX weekly expiration. positions I opened last friday closed at the bell. been running through the numbers for two hours because they’re cleaner than I expected and I want to understand why before I convince myself it means something.
where the theta book ended up #
april has been strange. not bad — strange. the tariff chaos in week one compressed premiums and triggered the event risk throttle, but we got out of that week with +$6,200 net. wrote that up april 13th.
weeks two and three were quieter. vol normalized, IV rank settled back into the 28-35% range my iron condors are built for, and theta started collecting the way it’s supposed to. week two: clean +$3,600. week three had a wrinkle — one QQQ condor got squeezed wednesday when QQQ ran hard post-FOMC language revision, I rolled the put side out a week and paid $1,400 in debit to get out of the hot zone. net week three: +$2,100 after the roll cost.
this week — week four — had a few things going at once: two-day live run of the NQ adaptive lookback I wrote about wednesday, plus today’s expiration.
final numbers:
- SPX weekly positions expired worthless today. both legs. full premium collected
- QQQ rolled position from last week cleared its theta window, took it off at 80% max profit thursday
- NQ adaptive lookback, live since wednesday: +$1,100 on thursday and friday in a low-momentum chop environment
- crypto: -$800, BTC stuck between $88k and $93k most of the week, no directional conviction in the momentum signal
- commissions and overhead: -$1,100
april MTD through today: +$15,400 (+1.3% on account)
that puts the account at roughly $1.212M heading into next week. Q1 was barely positive (+0.09%), so YTD through april is sitting around +1.4%. not flashy, but the structures are working. premium selling is doing what it’s supposed to do in a normalizing vol environment.
tracking theta decay: how I actually think about the book #
the thing about premium selling that took me too long to internalize: theta isn’t linear. you don’t collect the same time value every day. it accelerates.
at 30 DTE, an SPX iron condor with $25k notional might earn maybe $42 of theta per day. same position at 10 DTE: $73/day. at 3 DTE: $130+/day. the math is approximately:
theta_daily ≈ base_theta × √(30 / DTE)
meaning the last 25% of the holding period generates nearly half the total premium. if you exit early, you’re leaving money. if you hold too long, gamma explodes and theta becomes a trap.
this is why I built a real-time theta tracker. I need to know where I am on the curve at every moment in the position lifecycle.
from dataclasses import dataclass
from datetime import date
from typing import Optional
import asyncio
import aiohttp
import redis.asyncio as aioredis
@dataclass
class PositionTheta:
symbol: str
expiry: date
strike: float
option_type: str # 'call' or 'put'
contracts: int
theta_per_contract: float # daily theta per contract
@property
def dte(self) -> int:
return (self.expiry - date.today()).days
@property
def total_daily_theta(self) -> float:
return self.theta_per_contract * self.contracts * 100 # 100 multiplier
@property
def acceleration_factor(self) -> float:
"""normalized to 1.0 at 30 DTE, grows as expiry approaches"""
baseline = 30
if self.dte <= 0:
return float("inf")
return (baseline / self.dte) ** 0.5
class ThetaBookTracker:
def __init__(self, redis_client: aioredis.Redis, thetadata_base_url: str):
self.redis = redis_client
self.base_url = thetadata_base_url
self.positions: list[PositionTheta] = []
async def refresh_theta(self, pos: PositionTheta) -> Optional[float]:
"""pull current theta from ThetaData, cache 5 minutes in Redis"""
key = f"theta:{pos.symbol}:{pos.expiry}:{pos.strike}:{pos.option_type}"
cached = await self.redis.get(key)
if cached:
return float(cached)
async with aiohttp.ClientSession() as session:
params = {
"root": pos.symbol,
"expiry": pos.expiry.strftime("%Y%m%d"),
"strike": int(pos.strike * 1000),
"type": pos.option_type[0].upper(),
}
async with session.get(
f"{self.base_url}/v2/snapshot/option/greeks",
params=params,
) as resp:
if resp.status != 200:
return None
data = await resp.json()
theta = data.get("theta")
if theta is not None:
await self.redis.setex(key, 300, str(theta))
return theta
async def book_summary(self) -> dict:
"""concurrent theta refresh for all positions"""
thetas = await asyncio.gather(
*[self.refresh_theta(p) for p in self.positions]
)
total_theta = 0.0
accel_weighted = 0.0
for pos, theta in zip(self.positions, thetas):
if theta is None:
continue
pos.theta_per_contract = theta
daily = pos.total_daily_theta
total_theta += daily
accel_weighted += daily * pos.acceleration_factor
return {
"total_daily_theta": round(total_theta, 2),
"accel_weighted_theta": round(accel_weighted, 2),
"position_count": len(self.positions),
}
accel_weighted_theta is the number I actually watch live. it tells me how much of today’s theta collection is happening in the high-gamma zone near expiry. when that number is more than 1.6x total_daily_theta, I have positions deep in the danger zone and I need an exit plan.
the threshold isn’t scientific — it’s calibrated from about 14 months of watching this book. most positions that went bad on me hit 1.8x+ and I either didn’t have an exit ready or waited too long. the 1.6x threshold is early enough to force the decision while there’s still liquidity to roll cleanly.
the NexusFi community helped me think through the exit timing piece. there’s a thread on premium selling that’s been running for years — a lot of experienced sellers working through exactly when to defend vs when to let expire. more practical than most of what I found searching elsewhere.
here’s what the theta decay curve looked like for this month’s SPX book versus theoretical:
the dip around DTE 15-14 is the tariff week. IV exploded, short gamma was actively hurting open positions, and actual collected theta dropped well below theoretical. the event throttle kept me from getting destroyed but it didn’t protect existing short premium — it just stopped me from adding more.
the recovery is also visible: once vol normalized, actual theta overshot theoretical slightly (elevated IV meant richer premium even at the same DTE). that’s the tailwind you get after a vol spike. made up most of the tariff week deficit by the time we hit DTE 8-9.
colo queue backed up #
had a scheduled maintenance window thursday morning — 9 AM eastern, between pre-market and open. standard stuff: kernel patch, NVMe health check, log archive rotation.
except the log archive was 47GB.
back in february when I was debugging the IV rank websocket issue, I bumped the ThetaData snapshot retention to capture the full SPX options chain every 15 minutes for 90 days. fixed the actual problem, deployed the permanent fix two weeks ago, but never reset the retention window. 90 days of 15-minute full-chain snapshots. every. single. one. still on disk.
cleanup took eleven minutes. Redis latency on TimescaleDB reads dropped from 1.8ms average to 0.9ms average post-maintenance. probably a combination of the disk cleanup giving the controller breathing room and the scheduled reboot clearing some memory fragmentation. either way, 0.9ms average is where I want to be.
colo total cost: $680/month. for 0.9ms average latency on options data sitting on the CME network backbone, that’s still the best ROI line in my infrastructure budget.
here’s the week-by-week P&L for april:
tariff week looks like an outlier but it was the throttle earning its complexity cost. week four is the real baseline — what a normal post-vol-event week looks like when the systems are running clean. three grand in a week is not going to make anyone rich, but it’s predictable and it doesn’t require me to be right about market direction.
thursday hit different #
not sure how to write this without being weird about it. it’s 2:30 AM so here goes.
thursday was a genuinely good day. NQ adaptive lookback put on its first live trade — momentum signal fired short at 11:40 AM eastern, covered 80 minutes later, first-trade result was +$1,100. first confirmation the changes from wednesday actually work in real market conditions.
was logging the trade data around 8 PM. found myself thinking: I need to send this to dad.
just for half a second. then you remember.
three and a half years out and these still land. not often. just randomly, when something is good enough that your instinct goes looking for someone specific to tell. he was a VP of engineering — he would have actually understood what I’m building here. the adaptive signal work, the replay engine from last week. exactly the stuff he’d have spent an hour pulling apart with me.
it doesn’t hurt the same way it used to. it’s more like reaching for a door handle and finding the door isn’t there. brief, then you close the notebook and keep going.
A. knocked around 9 to ask if I wanted tea. I said yeah. she made it and left it on the desk without asking what I was thinking about. right call.
setting up next week’s condor structure over the weekend. may expiration cycle starts soon. NQ getting at least two more live weeks before I call the adaptive lookback a permanent upgrade. crypto watching for BTC to pick a direction above $93k or below $88k before adding momentum exposure.
clean close on a weird month. that’ll do.
-AK