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.
been putting off writing this one because it falls into the “should have built this two years ago” category. but Q1 attribution showed me exactly where the gaps are, and this is one of them.
the problem: I run SPX iron condors and QQQ spreads as roughly 60% of the account. premium selling, theta decay, short vega. the strategies backtest cleanly and live performance has been solid when conditions cooperate. but for two years I’ve been handling options contract lifecycle manually — or at least semi-manually. the system collected theta every day but rolled positions based on “AK looked at the dashboard and decided to roll.”
that’s not a system. that’s discretion wearing a system’s clothes.
after the Q1 factor attribution post (theta edge was real, delta drift and execution timing were the drag), I spent the last two weeks building what should have always existed: a fully automated options contract lifecycle manager. roll triggers, pin risk detection, expiry handling. no more manual override unless the system explicitly flags for human review.
why the final 7 days are different #
most options traders know this intuitively. here’s what the data actually shows on SPX positions specifically.
in the final 7 DTE window:
- gamma spikes — the rate of delta change accelerates dramatically near expiry. a 50-point SPX move at DTE=30 might shift your position delta by 2%. at DTE=2, the same move shifts it by 15-20%. your “stable” short spread is suddenly not stable.
- bid-ask spreads widen — market makers reduce size and widen quotes on near-expiry SPX options. what was a $0.10 spread at DTE=20 becomes $0.40-0.80 at DTE=2. your P&L cushion on closing trades evaporates in spread cost you didn’t model.
- pin risk activates — large open interest concentrations at specific strikes attract SPX to “pin” near those levels as market participants manage expiry hedges. your iron condor that looks safe at Thursday close can end up exactly between your short strikes on expiry Friday morning.
for a systematic strategy these aren’t just risks — they’re measurement problems. my theta attribution assumes spreads are within historical average. that assumption breaks hard in the final week. what looked like theta income in the P&L was partly spread cost that the log didn’t correctly isolate.
the fix is obvious in retrospect: roll at DTE=7-10, consistently, regardless of where the market is or whether the position feels comfortable. stop letting intuition override the exit criteria.
that sounds simple. the hard part was building the system to enforce it without exceptions.
the roll trigger engine #
three conditions trigger automatic roll evaluation:
1. DTE threshold breach — position’s nearest short leg crosses into the 7-day window. primary trigger, no exceptions.
2. delta drift breach — position delta moves outside ±0.05 normalized per $100k notional. catches directional moves that push the position out of neutral before DTE gets there.
3. IV rank spike — IV rank jumps >15 percentile points in a 24-hour window. forces a thesis review on whether the current strikes still reflect the intended entry setup.
import asyncio
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from typing import Optional, Literal
import pandas as pd
import numpy as np
import redis.asyncio as redis
import asyncpg
RollReason = Literal["dte_threshold", "delta_drift", "iv_spike", "manual_override"]
@dataclass
class OptionsPosition:
position_id: str
symbol: str
strategy: str
legs: list[dict] # {strike, right, expiry, quantity, entry_mid}
entry_ts: datetime
entry_iv_rank: float
entry_delta_norm: float # normalized delta per $100k notional
notional: float
@property
def dte(self) -> float:
nearest_expiry = min(
leg["expiry"] for leg in self.legs
if leg["quantity"] < 0 # short legs drive the DTE concern
)
delta = nearest_expiry - datetime.now(timezone.utc)
return delta.days + delta.seconds / 86400
@property
def is_near_expiry(self) -> bool:
return self.dte <= 7.0
@dataclass
class RollSignal:
position_id: str
reason: RollReason
current_dte: float
current_delta: float
current_iv_rank: float
urgency: Literal["immediate", "next_session", "monitor"]
notes: str = ""
class RollEngine:
DTE_THRESHOLD = 7.0
DELTA_DRIFT_LIMIT = 0.05 # per $100k notional
IV_SPIKE_THRESHOLD = 15.0 # percentile points in 24h
def __init__(self, redis_client: redis.Redis, pg_pool: asyncpg.Pool):
self.redis = redis_client
self.pg = pg_pool
async def evaluate_position(
self,
position: OptionsPosition,
current_greeks: dict,
current_iv_rank: float,
prior_iv_rank_24h: float,
) -> Optional[RollSignal]:
"""
Evaluate a single position for roll triggers.
Returns RollSignal if action needed, None if position is healthy.
"""
current_delta = current_greeks.get("portfolio_delta_norm", 0.0)
delta_drift = abs(current_delta - position.entry_delta_norm)
iv_change = current_iv_rank - prior_iv_rank_24h
# Priority 1: DTE threshold (always immediate if 3 days or fewer)
if position.dte <= self.DTE_THRESHOLD:
urgency = "immediate" if position.dte <= 3.0 else "next_session"
return RollSignal(
position_id=position.position_id,
reason="dte_threshold",
current_dte=position.dte,
current_delta=current_delta,
current_iv_rank=current_iv_rank,
urgency=urgency,
notes=f"DTE={position.dte:.1f} breached {self.DTE_THRESHOLD}-day threshold",
)
# Priority 2: Delta drift
if delta_drift > self.DELTA_DRIFT_LIMIT:
return RollSignal(
position_id=position.position_id,
reason="delta_drift",
current_dte=position.dte,
current_delta=current_delta,
current_iv_rank=current_iv_rank,
urgency="next_session",
notes=f"Delta drift {delta_drift:.3f} exceeds limit {self.DELTA_DRIFT_LIMIT}",
)
# Priority 3: IV spike (thesis review, not auto-roll)
if iv_change >= self.IV_SPIKE_THRESHOLD:
return RollSignal(
position_id=position.position_id,
reason="iv_spike",
current_dte=position.dte,
current_delta=current_delta,
current_iv_rank=current_iv_rank,
urgency="monitor",
notes=f"IV rank spiked {iv_change:.1f}pts in 24h — thesis review required",
)
return None
async def run_scan(self, positions: list[OptionsPosition]) -> list[RollSignal]:
"""Scan all active positions and return roll signals sorted by urgency."""
signals = []
for position in positions:
greeks_key = f"greeks:{position.position_id}:current"
iv_key = f"iv_rank:{position.symbol}:current"
iv_24h_key = f"iv_rank:{position.symbol}:24h_ago"
greeks_raw = await self.redis.hgetall(greeks_key)
current_greeks = {k.decode(): float(v) for k, v in greeks_raw.items()}
current_iv = float(await self.redis.get(iv_key) or 50.0)
prior_iv = float(await self.redis.get(iv_24h_key) or 50.0)
signal = await self.evaluate_position(
position, current_greeks, current_iv, prior_iv
)
if signal:
signals.append(signal)
urgency_order = {"immediate": 0, "next_session": 1, "monitor": 2}
return sorted(signals, key=lambda s: urgency_order[s.urgency])
async def log_signal(self, signal: RollSignal) -> None:
"""Persist roll signal to TimescaleDB for audit trail."""
async with self.pg.acquire() as conn:
await conn.execute("""
INSERT INTO roll_signals
(signal_ts, position_id, reason, dte, delta, iv_rank, urgency, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
""",
datetime.now(timezone.utc),
signal.position_id,
signal.reason,
signal.current_dte,
signal.current_delta,
signal.current_iv_rank,
signal.urgency,
signal.notes,
)
the scan runs every 5 minutes during market hours. redis gives me current greeks without hitting the broker API on every check — IB’s API can’t handle polling at that rate. every signal gets logged to timescaledb regardless of whether action is taken. the audit trail is the whole point.
pin risk detection #
pin risk is the weird one. not a standard greek. it’s emergent behavior from the market’s aggregate positioning at expiry.
the logic: if SPX is within roughly $10-15 of a strike carrying >50,000 contracts of open interest, there’s meaningful probability the index gets “pinned” near that level as dealers and market makers manage their expiry hedges. your iron condor with short strikes at 5200/5250 is going to behave differently if SPX closes Thursday at 5222 and the 5225 strike has 90,000 open contracts.
I built a pin risk scorer using ThetaData OI snapshots:
from dataclasses import dataclass
from typing import NamedTuple
class StrikeProfile(NamedTuple):
strike: float
call_oi: int
put_oi: int
total_oi: int
@dataclass
class PinRiskResult:
symbol: str
underlying_price: float
nearest_pin_strike: float
pin_distance: float # points from current price
pin_oi: int # total OI at pin strike
pin_score: float # 0-1 normalized risk score
is_elevated: bool # True if pin_score > 0.6
affected_positions: list[str] = None
def compute_pin_score(
strike_oi: int,
distance_pct: float, # distance as % of underlying
oi_rank_pct: float, # this strike's OI as percentile of all strikes
) -> float:
"""
Pin score from 0 (no concern) to 1 (maximum concern).
Weighted by proximity and OI concentration.
"""
# Proximity weight: full score within 0.1% of price, zero beyond 2%
proximity_weight = max(0.0, 1.0 - (distance_pct / 0.02))
# OI concentration weight: percentile already normalized 0-1
oi_weight = oi_rank_pct
return proximity_weight * 0.6 + oi_weight * 0.4
def detect_pin_risk(
underlying_price: float,
strike_profiles: list[StrikeProfile],
threshold: float = 0.6,
) -> PinRiskResult:
"""
Scan strike profiles for pin risk concentrations near current price.
Returns the highest-risk level detected.
"""
if not strike_profiles:
return PinRiskResult(
symbol="SPX",
underlying_price=underlying_price,
nearest_pin_strike=0,
pin_distance=float("inf"),
pin_oi=0,
pin_score=0.0,
is_elevated=False,
affected_positions=[],
)
all_oi = sorted([p.total_oi for p in strike_profiles])
max_score = 0.0
best_match = None
for profile in strike_profiles:
distance = abs(profile.strike - underlying_price)
distance_pct = distance / underlying_price
# Only evaluate strikes within 2% of current price
if distance_pct > 0.02:
continue
oi_percentile = all_oi.index(profile.total_oi) / max(len(all_oi), 1)
score = compute_pin_score(profile.total_oi, distance_pct, oi_percentile)
if score > max_score:
max_score = score
best_match = profile
if best_match is None:
return PinRiskResult(
symbol="SPX",
underlying_price=underlying_price,
nearest_pin_strike=underlying_price,
pin_distance=0.0,
pin_oi=0,
pin_score=0.0,
is_elevated=False,
affected_positions=[],
)
return PinRiskResult(
symbol="SPX",
underlying_price=underlying_price,
nearest_pin_strike=best_match.strike,
pin_distance=abs(best_match.strike - underlying_price),
pin_oi=best_match.total_oi,
pin_score=max_score,
is_elevated=max_score >= threshold,
affected_positions=[],
)
when is_elevated=True, the signal processor upgrades any position with short legs within $30 of the pin strike from “next_session” to “immediate” urgency. effectively treats detected pin risk as an emergency DTE condition.
gamma exposure chart #
this is what the system visualizes on a typical expiry Friday morning — gamma exposure by strike across my current SPX book:
gamma exposure by strike on a typical expiry friday. red bars are within ±$75 of SPX — the zone where gamma risk is meaningful. yellow stars are high-OI strikes flagged by the pin detector. blue dashed line is current SPX price. my short legs are positioned well outside this cluster in normal conditions. “well outside” shrinks fast when the market has a bad morning.
infrastructure: how the pieces connect #
three async services coordinate the full lifecycle:
roll_scanner.py — runs every 5 minutes during market hours, calls RollEngine.run_scan() against all active positions, pushes signals to a Redis stream at roll:signals.
pin_monitor.py — runs every 15 minutes, pulls ThetaData OI snapshots for SPX, runs detect_pin_risk(), publishes elevated flags to pin:risk:spx in Redis.
signal_processor.py — consumes from both Redis streams, cross-references pin flags to upgrade urgency on affected positions, dispatches actions:
urgency=immediate→ IB API call to initiate roll at market with a spread limiturgency=next_session→ Grafana alert, shows on morning dashboard reviewurgency=monitor→ logged silently, visible in the dashboard drill-down
TimescaleDB schema for the audit trail:
CREATE TABLE roll_signals (
signal_ts TIMESTAMPTZ NOT NULL,
position_id VARCHAR(64),
reason VARCHAR(32),
dte DOUBLE PRECISION,
delta DOUBLE PRECISION,
iv_rank DOUBLE PRECISION,
urgency VARCHAR(16),
action_taken VARCHAR(64),
notes TEXT,
PRIMARY KEY (signal_ts, position_id)
);
SELECT create_hypertable('roll_signals', 'signal_ts');
CREATE TABLE roll_executions (
execution_ts TIMESTAMPTZ NOT NULL,
signal_id BIGINT,
old_strikes JSONB,
new_strikes JSONB,
credit_received DOUBLE PRECISION,
execution_ms INT,
slippage_mid DOUBLE PRECISION,
notes TEXT
);
SELECT create_hypertable('roll_executions', 'execution_ts');
cross-referencing with the algo trading journal community on NexusFi has been useful for pressure-testing schema decisions — traders there who’ve been running automated systems for years have strong opinions on how to structure signal and execution audit tables. been a member since early 2023 and the infrastructure discussions are genuinely useful.
also worth digging into: timescaledb on GitHub — the hypertable design for time-series trading data is genuinely well-documented and the compression behavior on partitioned tables is worth understanding before you commit to a schema. the create_hypertable defaults are not optimal for high-frequency signal logging.
DTE management across the book #
second chart: how the system visualizes the current options book by DTE and daily theta collected.
each bubble is a live position. color: red = inside the roll zone, amber = approaching, green = comfortable. bubble size = pin risk score (larger = more concentrated OI near short strikes). the SPX-5100/5125 position at DTE=5 is in the roll zone with an elevated pin score — system flagged it immediate tuesday morning and executed the roll before open.
what changed in the first two weeks #
three positions rolled automatically since the system went live. zero manual intervention on the DTE triggers — the roll executed, logged, and I saw it in the dashboard the next morning.
spread costs on the automated rolls came in at 11% above mid on average. that’s within acceptable range given the current vol environment. my manual rolls before this system averaged 19% above mid — I was picking worse windows, probably anchoring on “feels comfortable” instead of “DTE says go.”
the iv_spike trigger fired once and correctly deferred to monitor status. the spike was a 2-hour tariff headline move that reversed by close. no roll was executed. that’s the correct no-action — the system waited for confirmation rather than reacting to noise.
pin risk detector flagged four situations since going live. two were genuine (one led to an urgency upgrade on the DTE=5 position). two resolved without action as SPX moved away from the OI concentration within the session. I’ll tune the threshold tighter after a full month of live data.
more importantly: I’m not watching positions the way I was. the system watches them. I check the Grafana dashboard at 8am before open, review overnight alerts, make any human decisions that need making. that’s the full extent of it. the afternoon of me manually checking IV every 30 minutes to decide if something “feels” like it should roll is done.
that’s the real value here. not the code specifically. the attention budget it returns.
April’s moving fast. Q2 is about closing the gaps that Q1 showed clearly. this was one of the obvious ones — the kind where you know what you should have built and you just hadn’t gotten there. getting there.
dad used to say “build it right the first time, it’s cheaper.” I’m three years late on that advice for this specific piece of infrastructure. better late.
-AK