Skip to main content

expiration day: everything is already automated

2:30 AM. may 15.

today is standard monthly options expiration. third friday of the month. the SPX condors i’ve been running since late april close today, or they already closed this week because the system flagged them as targets before i ever had to think about it.

i’m awake not because i’m stressed. i’m awake because i went down a rabbit hole reading options market microstructure papers around midnight and my brain doesn’t know how to stop. classic.

this post is about expiration week automation. what the system does differently when DTE is running out, why the rules change, and how i’ve made it so expiration days are genuinely boring.


why expiration week changes everything
#

regular theta selling works on a clean edge: implied vol is priced above realized, you sell that premium, collect time decay, close at a profit target and move on. the edge is real. the community over on NexusFi’s diversified option selling thread has been tracking this across portfolios since 2015 — that thread is 1.4 million views of traders running variations of the same basic approach. it works.

the problem is that in the final week before expiration, the edge degrades. fast.

the reason is gamma. gamma is the rate of change of delta — how much your delta exposure shifts per point move in the underlying. during normal operation, gamma is slow. a short put with 0.15 delta at 30 DTE stays at roughly 0.15 delta across a moderate move. annoying, but manageable.

as DTE collapses toward zero, gamma explodes. that same 0.15 delta short put might become a 0.45 delta position after a 100-point SPX move with 3 days to expiration. the mathematical reality is that near-expiration options behave less like premium selling vehicles and more like short gamma bets on the exact path of the underlying.

here’s what the gamma/theta ratio looks like as expiration approaches:

you can see the inflection happens around 21 DTE. from 45 to 21, the ratio moves slowly — theta is doing real work. inside 21 days, the curve starts bending. inside 10 days it’s exponential. inside 3 days you’re a completely different trade.

the theta you’re collecting is not worth the binary event risk you’re accepting. the edge is gone. get out.


the expiration calendar tracker
#

everything in the system starts with knowing what’s expiring when and what phase we’re in. this runs as a cron job every morning at 5 AM and publishes the result to redis for every other component to read.

import calendar
import redis
import json
from datetime import date
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class ExpirationEvent:
    expiration_date: date
    dte: int
    cycle_label: str           # e.g. "May 2026"
    phase: str                 # NORMAL, EXPIRATION_WEEK_ALERT, etc.
    is_expiration_week: bool
    is_expiration_day: bool


class ExpirationCalendar:
    """
    Tracks standard monthly option expirations (third Friday) and
    publishes phase state to Redis for system-wide access.

    Phase ladder:
      NORMAL              → 22+ DTE: standard operation
      EXPIRATION_WEEK_ALERT → 15-21 DTE: last chance for new entries
      REDUCE_ENTRY        → 8-14 DTE: no new entries in expiring cycle
      ACTIVE_MANAGEMENT   → 3-7 DTE: tighter targets and delta limits
      CLOSE_ALL           → 1-2 DTE: close regardless of P&L
      EXPIRATION_DAY      → 0 DTE: nothing should be open; confirm and exit
    """

    PHASE_THRESHOLDS = {
        'EXPIRATION_DAY': 0,
        'CLOSE_ALL': 2,
        'ACTIVE_MANAGEMENT': 7,
        'REDUCE_ENTRY': 14,
        'EXPIRATION_WEEK_ALERT': 21,
    }

    def __init__(self, redis_client: Optional[redis.Redis] = None):
        self.redis = redis_client
        self._cache: list[ExpirationEvent] = []

    def _third_friday(self, year: int, month: int) -> date:
        """Return the third Friday of a given year/month."""
        weeks = calendar.monthcalendar(year, month)
        fridays = [w[calendar.FRIDAY] for w in weeks if w[calendar.FRIDAY] != 0]
        return date(year, month, fridays[2])

    def _classify_phase(self, dte: int) -> str:
        if dte <= 0:
            return 'EXPIRATION_DAY'
        elif dte <= self.PHASE_THRESHOLDS['CLOSE_ALL']:
            return 'CLOSE_ALL'
        elif dte <= self.PHASE_THRESHOLDS['ACTIVE_MANAGEMENT']:
            return 'ACTIVE_MANAGEMENT'
        elif dte <= self.PHASE_THRESHOLDS['REDUCE_ENTRY']:
            return 'REDUCE_ENTRY'
        elif dte <= self.PHASE_THRESHOLDS['EXPIRATION_WEEK_ALERT']:
            return 'EXPIRATION_WEEK_ALERT'
        else:
            return 'NORMAL'

    def get_upcoming(self, as_of: date = None, count: int = 4) -> list[ExpirationEvent]:
        """Get the next N monthly expirations relative to as_of date."""
        as_of = as_of or date.today()
        result = []
        year, month = as_of.year, as_of.month

        while len(result) < count:
            exp_date = self._third_friday(year, month)
            dte = (exp_date - as_of).days
            if dte >= 0 or (dte < 0 and len(result) == 0):  # include today if it's expiration day
                result.append(ExpirationEvent(
                    expiration_date=exp_date,
                    dte=max(dte, 0),
                    cycle_label=exp_date.strftime('%b %Y'),
                    phase=self._classify_phase(dte),
                    is_expiration_week=dte <= 7,
                    is_expiration_day=dte <= 0
                ))
            month += 1
            if month > 12:
                month = 1
                year += 1

        return result

    def publish_state(self) -> dict:
        """Push current expiration state to Redis (24h TTL, refreshed daily)."""
        events = self.get_upcoming()
        nearest = events[0]

        state = {
            'as_of': date.today().isoformat(),
            'nearest_expiration': nearest.expiration_date.isoformat(),
            'nearest_dte': nearest.dte,
            'current_phase': nearest.phase,
            'is_expiration_week': nearest.is_expiration_week,
            'is_expiration_day': nearest.is_expiration_day,
            'upcoming': [
                {
                    'date': e.expiration_date.isoformat(),
                    'dte': e.dte,
                    'cycle': e.cycle_label,
                    'phase': e.phase,
                }
                for e in events
            ]
        }

        if self.redis:
            self.redis.setex(
                'expiration:calendar:state',
                86400,  # 24-hour TTL
                json.dumps(state)
            )

        return state


def get_phase_from_redis(redis_client: redis.Redis) -> str:
    """
    Convenience function for any system component to check current phase.
    Falls back to NORMAL if redis is unavailable.
    """
    try:
        raw = redis_client.get('expiration:calendar:state')
        if raw:
            return json.loads(raw).get('current_phase', 'NORMAL')
    except (redis.RedisError, json.JSONDecodeError):
        pass
    return 'NORMAL'

the get_phase_from_redis() function is what every other component in the system calls. the entry engine checks it before opening positions. the greeks monitor uses it to decide how tight delta breach alerts should be. the roll logic uses it to pick between the 50% and 75% profit targets.

one redis GET replaces the same DTE calculation being independently run in six different places. that’s the architecture: compute once, distribute.


how each phase changes system behavior
#

the phase table is simple but took a lot of trial and error to get right:

phase DTE range entry engine profit target delta alert threshold close trigger
NORMAL 22+ open freely 50% max profit ±0.25 delta breach profit target only
EXPIRATION_WEEK_ALERT 15–21 last cycle entries allowed 50% ±0.25 profit target only
REDUCE_ENTRY 8–14 no new entries this cycle 50% ±0.20 tightened profit target only
ACTIVE_MANAGEMENT 3–7 no entries 75% ±0.15 tightened 75% target or delta breach
CLOSE_ALL 1–2 no entries any profit = close any delta move = close P&L-agnostic close
EXPIRATION_DAY 0 no entries immediate close n/a market order close

the big difference is the profit target in ACTIVE_MANAGEMENT. normally i run 50% of max profit as the close target — standard options selling discipline. inside 7 DTE i tighten that to 75%. this sounds backwards (more greedy late?) but the logic is: the position has already decayed significantly, gamma risk is increasing, and the incremental edge on the remaining premium doesn’t justify holding through potential gamma explosions. take 75% and walk away clean.

the CLOSE_ALL phase at 1–2 DTE is non-negotiable. close everything regardless of P&L. if i’m somehow still in a losing position two days before expiration, the right move is to cut it and accept the loss, not gamble on a recovery through expiration. that gamble sometimes works. the times it doesn’t are catastrophic.


the infrastructure side: colo on expiration day
#

normal days, the chicago colocation server is handling execution but latency barely matters. i’m running premium selling strategies with multi-week holding periods. fills at 20ms versus 200ms are identical for my P&L.

expiration day is different. anything that still needs to close in the morning, i want filled fast and clean. the colo has a dedicated process that activates on any day where is_expiration_day is true in the redis state.

import asyncio
import logging
from ib_insync import IB, Option, MarketOrder, LimitOrder
from datetime import datetime

logger = logging.getLogger('expiration_closer')


class ExpirationDayCloser:
    """
    Runs on the colo machine on monthly expiration day.
    Scans for any open positions in the expiring series and
    submits close orders at market open.
    Activates at 6:00 AM Chicago time.
    """

    def __init__(self, ib: IB, redis_client, expiry_date: str):
        self.ib = ib
        self.redis = redis_client
        self.expiry_date = expiry_date  # YYYYMMDD format
        self.closed_positions = []
        self.failed_positions = []

    async def scan_open_positions(self) -> list:
        """Find any open option positions in the expiring cycle."""
        portfolio = await asyncio.wait_for(
            asyncio.to_thread(self.ib.portfolio),
            timeout=15.0
        )

        expiring = []
        for item in portfolio:
            contract = item.contract
            if (hasattr(contract, 'lastTradeDateOrContractMonth') and
                    contract.lastTradeDateOrContractMonth == self.expiry_date and
                    abs(item.position) > 0):
                expiring.append(item)
                logger.info(
                    f"Found expiring position: {contract.symbol} "
                    f"{contract.right} {contract.strike} "
                    f"qty={item.position} unrealizedPNL={item.unrealizedPNL:.0f}"
                )

        return expiring

    async def close_position(self, portfolio_item, use_market_order: bool = False):
        """
        Close a position. Uses limit at midpoint first,
        falls back to market if not filled within 60 seconds.
        """
        contract = portfolio_item.contract
        position = portfolio_item.position
        action = 'BUY' if position < 0 else 'SELL'
        qty = abs(position)

        if use_market_order:
            order = MarketOrder(action, qty)
            logger.info(f"Submitting MARKET order: {action} {qty} {contract.localSymbol}")
        else:
            # try mid-point limit first
            ticker = self.ib.reqMktData(contract, '', False, False)
            await asyncio.sleep(2)  # wait for quote
            mid = (ticker.bid + ticker.ask) / 2 if ticker.bid and ticker.ask else None

            if mid and action == 'BUY':
                limit_price = round(mid * 1.02, 2)  # 2% above mid to get filled
            elif mid and action == 'SELL':
                limit_price = round(mid * 0.98, 2)
            else:
                # no quote, go market
                order = MarketOrder(action, qty)
                logger.warning(f"No quote available, using MARKET order for {contract.localSymbol}")
                use_market_order = True

            if not use_market_order:
                order = LimitOrder(action, qty, limit_price)
                logger.info(
                    f"Submitting LIMIT order: {action} {qty} {contract.localSymbol} "
                    f"@ {limit_price}"
                )

        trade = self.ib.placeOrder(contract, order)

        # wait up to 90 seconds for fill
        deadline = asyncio.get_event_loop().time() + 90
        while asyncio.get_event_loop().time() < deadline:
            await asyncio.sleep(5)
            if trade.orderStatus.status in ('Filled', 'Cancelled'):
                break

        if trade.orderStatus.status == 'Filled':
            self.closed_positions.append({
                'symbol': contract.localSymbol,
                'qty': qty,
                'fill_price': trade.orderStatus.avgFillPrice,
                'order_type': 'MARKET' if use_market_order else 'LIMIT',
                'unrealized_at_close': portfolio_item.unrealizedPNL
            })
            logger.info(f"FILLED: {contract.localSymbol} @ {trade.orderStatus.avgFillPrice}")
        else:
            # limit didn't fill in time, escalate to market
            logger.warning(f"Limit order timeout for {contract.localSymbol}, escalating to market")
            self.ib.cancelOrder(trade.order)
            await asyncio.sleep(2)
            await self.close_position(portfolio_item, use_market_order=True)

    async def run(self):
        """Main expiration day routine."""
        logger.info(f"ExpirationDayCloser starting — expiry: {self.expiry_date}")

        positions = await self.scan_open_positions()

        if not positions:
            logger.info("No open positions in expiring cycle. Nothing to do.")
            self._publish_completion_to_redis(positions_closed=0)
            return

        logger.info(f"Found {len(positions)} positions to close")

        for item in positions:
            try:
                await self.close_position(item)
                await asyncio.sleep(3)  # spacing between orders
            except Exception as e:
                logger.error(f"Failed to close {item.contract.localSymbol}: {e}")
                self.failed_positions.append(item)

        self._publish_completion_to_redis(len(positions))
        logger.info(
            f"Expiration close complete. "
            f"Closed: {len(self.closed_positions)}, "
            f"Failed: {len(self.failed_positions)}"
        )

    def _publish_completion_to_redis(self, positions_closed: int):
        summary = {
            'expiry_date': self.expiry_date,
            'completed_at': datetime.now().isoformat(),
            'positions_closed': positions_closed,
            'successful': self.closed_positions,
            'failed': [p.contract.localSymbol for p in self.failed_positions]
        }
        self.redis.setex('expiration:close:summary', 86400, __import__('json').dumps(summary))

what i care about in expiration day execution is fill certainty, not fill quality. slight slippage is acceptable. not closing is not acceptable.

here’s what the fill data looks like comparing expiration days to normal days over the last 60 days of positions:

slippage is slightly worse on expiration day because bid-ask spreads widen as market makers hedge their own gamma exposure. fill time is actually faster because i’m using market orders for certainty. the tradeoff is worth it by a mile.


what the morning actually looks like
#

it’s 2:30 AM now. here’s what i expect to happen when i wake up at 6:30 and check the system:

positions in expiring may cycle:

  • four SPX condors i’ve been running since late april: closed tuesday at 80% of max profit. done by tuesday morning. system flagged them at profit target during ACTIVE_MANAGEMENT phase and closed automatically.
  • two QQQ credit spreads from may 2: closed wednesday morning at 75% target. done.
  • one small diagonal i rolled into may from the april cycle: limit order staged in the colo to close at market open. already in the queue. nothing to do.

effectively zero active work today. the expiration day closer will run at 6 AM chicago time, find that last diagonal still open, submit the order, fill it within two minutes, publish the result to redis.

i’ll check at 6:30, see the summary in redis, confirm. that’s it.

this is what good automation looks like. not exciting. the boring outcome is the win.


a few notes on the week + wrapping up
#

A. went to bed around 11 — she had a deadline sprint all week at work. the apartment’s been quiet since then, which is actually good working conditions.

MTD through yesterday i’m running at about +0.6% on the book. quiet may so far, which is fine. i wrote the one-year system check on monday when i couldn’t sleep, which probably set the tone for this whole week of late-night posts.

may is a weird month. parents used to do this big memorial day thing on the beach. burgers, my dad’s terrible attempts at margaritas, random people showing up. first week of summer in san diego. i don’t think about it constantly anymore, but it shows up in flashes, especially at 2 AM when i’m the only one awake. it’s fine. it just is what it is.

expiration day. the machine will handle it. i’m going to bed.

-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.
roll logic: automating when to hold, roll, or close short premium
2:15 AM wednesday. A. made pasta around 7, crashed by 10 - she had a brutal deadline at work today. i cleaned the kitchen, sat down to “just check something,” and have been staring at this roll management code for four hours.
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%.
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.
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.