Skip to main content

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.

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

Related

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.
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.
real-time portfolio Greeks: aggregating delta, gamma, theta, vega at scale
2:15 AM friday. couldn’t sleep after the week we just had. VIX ripped to 28 monday, calmed down midweek, then did that whipsaw thing thursday afternoon where you think it’s done but it’s absolutely not done.
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.