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