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_MANAGEMENTphase 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