Skip to main content

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.

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 limit
  • urgency=next_session → Grafana alert, shows on morning dashboard review
  • urgency=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

Related

real-time greeks aggregation: knowing your portfolio delta/gamma at sub-second speed
2:15am wednesday. still processing this week. the q1 factor attribution post from sunday was cathartic but it also made me confront something i’d been papering over: i was flying blind on real-time greeks for most of march. not completely blind — i had position-level greeks from IB’s TWS feed. but aggregating them into a coherent portfolio view? that was a manual spreadsheet thing i’d run every few hours.
event risk throttle: dynamic exposure scaling based on vol regime
2:30 AM monday. Q2 week 2 starts in a few hours. Been sitting with something since Thursday when I posted the Q2 week 1 numbers. Said we were running at 60% position size - waiting for the health scoring system to validate before going full deployment.
q2 week 1: health scoring live, colo nic split, first numbers
2:15am friday. Q2 week 1 is done. walked in from the kitchen, A. fell asleep at her desk again — laptop open, ambient music still running. grabbed a blanket from the couch and put it over her. then came back and pulled up the weekly numbers.
q1 close: final numbers, colo benchmarks, and q2 setup
friday night. Q1 officially in the books. did the math earlier while A. was cooking. she noticed i went quiet and just left me to it. that’s one of the things i didn’t expect about being married — how well she reads when to give space. anyway.
q1 factor attribution: theta is the edge, delta drift is the problem
q1 is in the books. three months, roughly flat performance, and a clear pattern in the trade data that tells me exactly what needs to change for q2. jan: +2.1%. feb: -1.3%. march: -0.9% (locked at friday close). quarter: -0.13% net. account moved from $1.196M to about $1.194M. call it flat with a slight downside tilt.
march vol spike: when the risk engine earns its keep
2:30am friday. rough week in the books. march has been a whole thing. tariff headlines dropping every 48 hours, VIX spiking then partially recovering, nobody knows what SPX does next. january was decent (+2.1%), february went against me (-1.3%). march hasn’t been great either. week ending today, i’m down about $2.3k for the five sessions. month’s probably closing around -1%.