Skip to main content

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.

this post is about a problem i’ve been procrastinating on for months. the theta harvest strategy is mostly automated. entry is automatic. Greeks monitoring via Prometheus alerts on delta breach has been running solid. but what actually happens when the alert fires? until last week: me, manually, making a judgment call.

not anymore.

the roll decision problem
#

premium selling is clean in theory. sell iron condors on SPX, collect time decay, close at profit target, repeat. the NexusFi thread on selling options on futures has 7,000+ replies on this exact problem - been lurking that thread since i joined in January 2023. the community broadly agrees: consistency in management rules matters more than which specific thresholds you pick. just pick something defensible and stick to it.

in practice, i had three failure modes:

late profit takes. position hits 50% of max profit, i’m busy, i don’t close immediately, it reverses. i close later for 25% or less. or i take a loss.

frozen at delta breach. short delta drifts past my threshold. i stare at it. i try to “feel” whether it’ll reverse. i make a late decision or no decision. usually bad.

inconsistent loss management. no clear rule for when to take the full loss vs roll out in time. completely inconsistent execution session to session.

the fix is encoding the rules and letting the machine decide. obvious. took me too long.

the decision tree
#

i backtested several variations on six months of SPX option chain data from ThetaData (november 2025 through april 2026). here’s what survived:

position state → action

unrealized_pnl >= 50% of initial_credit    → CLOSE (profit target)
dte <= 21 AND unrealized_pnl >= 25%        → CLOSE (take partial near expiry, gamma risk rising)
abs(short_delta) > 0.28 AND dte > 21      → ROLL (defend: roll breached side 2 strikes OTM, same expiry)
abs(short_delta) > 0.28 AND dte <= 21     → CLOSE (don't roll near expiry, gamma makes defense too expensive)
debit_to_close > 2.5x initial_credit      → CLOSE (loss limit, no exceptions)
dte <= 7                                   → CLOSE (never hold through final week gamma, ever)
else                                       → HOLD

50% profit target is fairly standard. 21 DTE is the classic gamma risk boundary. 0.28 delta is where i’ve found the original tent structure has stopped working - the position has moved enough that holding makes less sense than defending.

these rules apply per position, not per account. i run 4-5 SPX iron condors simultaneously at staggered expirations, each evaluated independently.

the code
#

here’s the core roll manager. ib_insync for execution, ThetaData for live Greeks, Redis pub/sub for the event pipeline.

import asyncio
import json
import redis.asyncio as redis
from dataclasses import dataclass
from typing import Optional
from ib_insync import IB, Contract, Order
from datetime import datetime
import logging

logger = logging.getLogger(__name__)


@dataclass
class PositionState:
    symbol: str
    expiration: str
    put_strike: float
    call_strike: float
    put_wing_strike: float   # long put (lower)
    call_wing_strike: float  # long call (higher)
    initial_credit: float
    current_debit: float     # current mark debit to close
    short_put_delta: float   # delta of the short put (negative, abs used)
    short_call_delta: float  # delta of the short call (positive)
    dte: int
    position_id: str
    contracts: int = 1

    @property
    def unrealized_pnl_pct(self) -> float:
        """Fraction of initial credit retained. 1.0 = full profit (expired worthless)."""
        return (self.initial_credit - self.current_debit) / self.initial_credit

    @property
    def debit_to_credit_ratio(self) -> float:
        return self.current_debit / self.initial_credit

    @property
    def max_short_delta(self) -> float:
        return max(abs(self.short_put_delta), abs(self.short_call_delta))

    @property
    def breached_side(self) -> Optional[str]:
        """Returns 'put', 'call', or None based on which side breached the delta threshold."""
        pd = abs(self.short_put_delta)
        cd = abs(self.short_call_delta)
        THRESHOLD = 0.28
        if pd > THRESHOLD and pd >= cd:
            return 'put'
        if cd > THRESHOLD and cd > pd:
            return 'call'
        return None


class Decision:
    CLOSE_PROFIT = "close_profit"
    CLOSE_LOSS   = "close_loss"
    CLOSE_DTE    = "close_dte"
    CLOSE_GAMMA  = "close_gamma_breach"
    ROLL_SIDE    = "roll_side"
    HOLD         = "hold"


class RollManager:
    def __init__(self, ib: IB, redis_client: redis.Redis, db_conn):
        self.ib        = ib
        self.redis     = redis_client
        self.db        = db_conn
        self.positions: dict[str, PositionState] = {}
        self._lock     = asyncio.Lock()

    def evaluate(self, pos: PositionState) -> tuple[str, str]:
        """
        Pure decision function - no side effects.
        Returns (Decision constant, human-readable reason).
        Called on every Greeks update pushed from ThetaData.
        """
        # Priority 1: Hard loss limit. Non-negotiable.
        if pos.debit_to_credit_ratio >= 2.5:
            return Decision.CLOSE_LOSS, (
                f"debit={pos.current_debit:.2f} is "
                f"{pos.debit_to_credit_ratio:.1f}x initial credit={pos.initial_credit:.2f}"
            )

        # Priority 2: Final week - close everything, no exceptions.
        if pos.dte <= 7:
            return Decision.CLOSE_DTE, f"dte={pos.dte} entering final week, closing"

        # Priority 3: Profit targets.
        if pos.unrealized_pnl_pct >= 0.50:
            return Decision.CLOSE_PROFIT, f"pnl={pos.unrealized_pnl_pct:.1%}, hit 50% target"

        if pos.dte <= 21 and pos.unrealized_pnl_pct >= 0.25:
            return Decision.CLOSE_PROFIT, (
                f"pnl={pos.unrealized_pnl_pct:.1%} at dte={pos.dte}, "
                f"taking 25% near expiry to avoid gamma"
            )

        # Priority 4: Delta breach.
        breached = pos.breached_side
        if breached:
            delta_val = abs(pos.short_put_delta if breached == 'put' else pos.short_call_delta)
            if pos.dte > 21:
                return Decision.ROLL_SIDE, (
                    f"{breached} delta={delta_val:.3f} > 0.28, dte={pos.dte} > 21, rolling"
                )
            else:
                return Decision.CLOSE_GAMMA, (
                    f"{breached} delta={delta_val:.3f} breach at dte={pos.dte}, "
                    f"closing instead of rolling (gamma too high)"
                )

        return Decision.HOLD, "within parameters"

    async def execute_close(self, pos: PositionState, reason: str) -> bool:
        logger.info(f"[CLOSE] {pos.position_id} | reason: {reason}")
        try:
            combo = self._build_ic_contract(pos)
            order = Order(
                action='BUY',
                totalQuantity=pos.contracts,
                orderType='LMT',
                lmtPrice=round(pos.current_debit + 0.05, 2),  # slight buffer
                transmit=True,
                tif='DAY'
            )
            self.ib.placeOrder(combo, order)
            await asyncio.sleep(2)
            await self._log_action(pos, 'close', reason, pos.current_debit)
            return True
        except Exception as e:
            logger.error(f"Close failed {pos.position_id}: {e}")
            return False

    async def execute_roll(self, pos: PositionState, reason: str) -> bool:
        """Roll the breached side 2 strikes further OTM, same expiration."""
        side = pos.breached_side
        if not side:
            return False

        logger.info(f"[ROLL] {pos.position_id} | {side} side | reason: {reason}")

        # move 2 standard SPX strikes (5-wide) in the defensive direction
        STRIKE_WIDTH = 5
        if side == 'put':
            new_short  = pos.put_strike      - (2 * STRIKE_WIDTH)
            new_long   = pos.put_wing_strike - (2 * STRIKE_WIDTH)
        else:
            new_short  = pos.call_strike      + (2 * STRIKE_WIDTH)
            new_long   = pos.call_wing_strike + (2 * STRIKE_WIDTH)

        try:
            roll_credit = await self._price_roll(pos, side, new_short, new_long)

            if roll_credit < -(pos.initial_credit * 0.15):
                # Rolling costs more than 15% of original credit - not worth it, close instead
                logger.warning(
                    f"Roll debit too high ({roll_credit:.2f}), closing {pos.position_id}"
                )
                return await self.execute_close(pos, f"roll cost exceeded threshold: {reason}")

            await self._log_action(pos, 'roll', reason, roll_credit)
            return True
        except Exception as e:
            logger.error(f"Roll failed {pos.position_id}: {e}")
            return False

    async def process_greeks_update(self, position_id: str, update: dict):
        """Entry point for ThetaData Greeks events published via Redis."""
        async with self._lock:
            if position_id not in self.positions:
                return

            pos = self.positions[position_id]
            pos.short_put_delta  = update.get('put_delta',  pos.short_put_delta)
            pos.short_call_delta = update.get('call_delta', pos.short_call_delta)
            pos.current_debit    = update.get('mark_debit', pos.current_debit)
            pos.dte              = update.get('dte',         pos.dte)

            decision, reason = self.evaluate(pos)

            if decision == Decision.HOLD:
                return

            logger.info(f"Decision: {decision} | {reason}")

            if decision in (Decision.CLOSE_PROFIT, Decision.CLOSE_LOSS,
                            Decision.CLOSE_DTE, Decision.CLOSE_GAMMA):
                await self.execute_close(pos, reason)
            elif decision == Decision.ROLL_SIDE:
                await self.execute_roll(pos, reason)

    async def listen(self):
        """Subscribe to greeks:updates channel and process events."""
        pubsub = self.redis.pubsub()
        await pubsub.subscribe('greeks:updates')
        logger.info("RollManager listening on greeks:updates")

        async for message in pubsub.listen():
            if message['type'] != 'message':
                continue
            try:
                payload = json.loads(message['data'])
                pid = payload.get('position_id')
                if pid:
                    await self.process_greeks_update(pid, payload)
            except Exception as e:
                logger.error(f"Error processing message: {e}")

    # --- helpers (full implementation in private repo) ---

    def _build_ic_contract(self, pos: PositionState) -> Contract:
        """Build 4-leg combo contract for IB API."""
        ...

    async def _price_roll(
        self, pos: PositionState, side: str, new_short: float, new_long: float
    ) -> float:
        """Get mid-market credit/debit for the roll combination from IB."""
        ...

    async def _log_action(
        self, pos: PositionState, action: str, reason: str, price: float
    ):
        await self.db.execute(
            """
            INSERT INTO roll_history
              (position_id, action, reason, price, dte_at_action,
               pnl_pct_at_action, short_put_delta, short_call_delta, ts)
            VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())
            """,
            pos.position_id, action, reason, price,
            pos.dte, pos.unrealized_pnl_pct,
            pos.short_put_delta, pos.short_call_delta
        )

evaluate() is a pure function - no async, no side effects. easy to unit test. the async complexity lives in execution and the Redis listener. the lock prevents a race where two consecutive Greeks updates both see the same state and both try to act.

what the backtest shows
#

compared five management approaches on 6 months of SPX iron condors. same entry signals, same initial position sizing. just different management rules.

the “rule-based (automated)” vs “ad hoc (me)” comparison is the interesting one. win rates are similar (70% vs 67%). average P&L per contract is better ($318 vs $255). but max drawdown is the big gap: $870 vs $1,590 per contract. the rule-based system cuts losers consistently. my ad hoc version held some positions too long hoping they’d recover.

that max drawdown difference scales. at 10 contracts per cycle, that’s $7,200 in avoided worst-case drawdown from one cycle.

the ThetaData bridge
#

ThetaData pushes live Greeks every ~2 minutes during market hours via their websocket. i run a bridge service that subscribes, maps to position IDs, and publishes to Redis:

async def thetadata_greeks_bridge(roll_manager: RollManager, redis_client: redis.Redis):
    """
    Bridge: ThetaData websocket → Redis pub/sub → RollManager.
    Runs as a separate async task alongside the RollManager listener.
    """
    from thetadata import ThetaClient, GreekStreamSnapshot

    client = ThetaClient(username=THETA_USER, passwd=THETA_PASS)

    async with client.connect():
        monitored = list(roll_manager.positions.keys())
        logger.info(f"Streaming Greeks for {len(monitored)} positions")

        async for snap in client.stream_greeks(monitored):
            snap: GreekStreamSnapshot

            expiry_date = snap.expiration.date()
            dte = (expiry_date - datetime.now().date()).days

            payload = json.dumps({
                'position_id': snap.position_id,
                'put_delta':   snap.put_delta,
                'call_delta':  snap.call_delta,
                'mark_debit':  snap.mark_mid_price,
                'dte':         dte,
                'ts':          snap.timestamp.isoformat()
            })

            await redis_client.publish('greeks:updates', payload)

roll manager and bridge run concurrently in the same asyncio event loop:

async def main():
    ib = IB()
    await ib.connectAsync('127.0.0.1', 7497, clientId=5)

    redis_client = redis.from_url('redis://localhost:6379')
    db_conn = await asyncpg.connect(TIMESCALE_DSN)

    manager = RollManager(ib, redis_client, db_conn)

    # Load open positions from TimescaleDB on startup
    await manager.load_positions_from_db()

    # Run bridge and manager concurrently
    await asyncio.gather(
        manager.listen(),
        thetadata_greeks_bridge(manager, redis_client)
    )

asyncio.run(main())

TimescaleDB keeps the roll history. i can query things like “how often did we roll vs close outright?” and “what was the average DTE when we triggered a close?” the data accumulates over time and makes the rule calibration easier.

position lifecycle visualization
#

this chart shows how unrealized P&L % of initial credit evolves over a position’s life (DTE counting down from 45 to 0) under three market conditions. thresholds show where the automated rules kick in.

quiet scenario hits 50% profit around DTE 28-30. trending scenario plateaus then deteriorates - triggers the delta breach rule around DTE 35-38, gets rolled or closed. vol spike scenario (april 2025, april 2026 were both this) blows through the loss limit fast - system closes around DTE 32-35 instead of letting it run to max loss.

if you want the conceptual foundation for iron condor management in depth, the NexusFi Academy iron condors guide goes through the Greeks mechanics well. the rule calibration part is specific to your risk tolerance but the underlying framework is solid.

12 days live
#

not dramatic results yet. but one real test already: may 9 saw a brief VIX spike above 20. my short put on the may-14 expiry condor hit 0.31 delta around 10:15 AM. system fired the CLOSE_GAMMA action (DTE was 5 - below the 21 DTE roll threshold). position closed. i got the Slack alert at 10:16.

manual me would have watched it for another 30 minutes. probably closed for more loss.

the real value is mental bandwidth. i stopped watching positions during market hours. system handles it, logs everything to TimescaleDB, i review the history in the evening. that’s the actual goal - not squeezing out extra P&L, just removing human inconsistency from a strategy that works when executed consistently.


quiet in the apartment at 2:30 AM. reminded me of when i was 16, learning python at this exact hour. dad would come downstairs around 2, see the laptop open, just hand me a glass of water and go back to bed. never said anything about sleep. i think he knew i wasn’t going to stop.

miss that.

alright. code’s committed. going to bed.

-AK

Related

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%.
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.
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.
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.
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.
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.