Strategy Guide

Iron Condor and Multi-Leg Options Strategies for AI Agents

Master the full lifecycle of iron condor positions: from IV rank entry signals to delta-neutral management, theta harvesting, and dynamic rolling — all automated for autonomous agents.

4-leg
Position Complexity
Theta+
Primary Edge
IV Rank
Entry Signal
~0.30δ
Typical Short Strike
Strategy Options AI Agents Theta Decay Volatility

1. Iron Condor Mechanics: The Four-Leg Architecture

An iron condor is a defined-risk, non-directional options strategy that profits when the underlying asset trades within a bounded range until expiration. It consists of four simultaneous legs that together create a "condor-shaped" payoff profile.

The structure combines two vertical spreads: a bear call spread above the market and a bull put spread below. The net result is a position that collects premium upfront and retains it as long as price stays inside the breakeven range.

The Four Legs Defined

LegActionStrike PositionRole
Short CallSellOTM above marketDefines upper profit boundary
Long CallBuyFurther OTM above short callCaps max loss on upside
Short PutSellOTM below marketDefines lower profit boundary
Long PutBuyFurther OTM below short putCaps max loss on downside

Payoff Zones and Break-Even Calculation

When entering the trade, a net credit is received. This credit defines the maximum profit, while the width of either vertical spread minus the credit defines maximum loss.

Profit/Loss at Expiration: Max Loss Max Profit Zone Max Loss (down) (between short strikes) (up) | | ─────────+──────────────────────────────────────────+───────── | | Long Put | Short Put ←── Net Credit ──→ Short Call | Long Call Strike | Strike Strike | Strike | | Lower BE = Short Put Strike - Net Credit Received Upper BE = Short Call Strike + Net Credit Received Max Profit = Net Credit Received Max Loss = Spread Width - Net Credit Received

Probability of Profit

Iron condors typically carry a 60-75% probability of profit at standard strike selections (approximately the 16-delta short strikes, placing each breakeven roughly one standard deviation from current price). However, winning trades are small relative to occasional losing trades — risk management is paramount.

68%
Avg PoP at 16-delta strikes
0.45x
Credit-to-width ratio target
30-45
Optimal DTE at entry
50%
Max loss trigger to close

2. Delta Management and Directional Risk

While iron condors are designed to be delta-neutral at entry, price movement immediately creates directional exposure. An agent must monitor net delta continuously and make adjustments before a losing leg threatens the max loss threshold.

Understanding Net Position Delta

The four legs each carry their own delta. At equilibrium, the short put's positive delta and the short call's negative delta cancel. As price moves toward a short strike, that leg's delta grows, skewing the position.

# Delta monitoring for a live iron condor
from dataclasses import dataclass
from typing import Dict
import numpy as np

@dataclass
class OptionLeg:
    strike: float
    leg_type: str   # 'call' or 'put'
    position: int    # +1 long, -1 short
    quantity: int
    delta: float

class IronCondorMonitor:
    def __init__(self):
        self.legs: Dict[str, OptionLeg] = {}
        self.delta_threshold = 0.12   # trigger adjustment
        self.max_loss_pct = 0.50       # close at 50% max loss

    def net_delta(self) -> float:
        total = 0.0
        for leg in self.legs.values():
            total += leg.delta * leg.position * leg.quantity * 100
        return total

    def needs_adjustment(self) -> bool:
        return abs(self.net_delta()) > self.delta_threshold

    def adjustment_direction(self) -> str:
        nd = self.net_delta()
        if nd > self.delta_threshold:
            return "short_call_breached_roll_up"
        elif nd < -self.delta_threshold:
            return "short_put_breached_roll_down"
        return "neutral"

Delta-Neutral Adjustment Strategies

When net delta exceeds the threshold, an agent has three primary adjustment tools:

Agent Warning

Do not roll a short strike that is already in-the-money. Closing the position outright at a defined loss is almost always preferable to chasing a debit roll on an ITM leg.

3. Theta Decay Profile and Time-Based Management

Theta (time decay) is the iron condor's primary profit engine. Options lose value every day as expiration approaches — iron condors sell this decay. Understanding theta's non-linear behavior is critical for agent-level automation.

The Theta Acceleration Curve

Theta decay is not linear. It accelerates sharply in the final 30 days before expiration, with the most rapid decay occurring in the last two weeks. An iron condor entered at 45 DTE will see roughly 40% of its total theta in the last 15 days — but gamma risk also spikes in this window.

import numpy as np
import scipy.stats as stats

def black_scholes_theta(S, K, T, r, sigma, option_type='call'):
    """Theta in dollars per day for one contract (100 shares)."""
    if T <= 0:
        return 0
    d1 = (np.log(S/K) + (r + 0.5*sigma**2)*T) / (sigma*np.sqrt(T))
    d2 = d1 - sigma*np.sqrt(T)
    nd1 = stats.norm.pdf(d1)

    if option_type == 'call':
        theta = (-(S*nd1*sigma)/(2*np.sqrt(T)) - r*K*np.exp(-r*T)*stats.norm.cdf(d2)) / 365
    else:
        theta = (-(S*nd1*sigma)/(2*np.sqrt(T)) + r*K*np.exp(-r*T)*stats.norm.cdf(-d2)) / 365

    return theta * 100   # per contract

def condor_daily_theta(S, short_call, long_call, short_put, long_put, T, r, sigma):
    """Net theta of full iron condor position."""
    sc_theta = black_scholes_theta(S, short_call, T, r, sigma, 'call')
    lc_theta = black_scholes_theta(S, long_call, T, r, sigma, 'call')
    sp_theta = black_scholes_theta(S, short_put, T, r, sigma, 'put')
    lp_theta = black_scholes_theta(S, long_put, T, r, sigma, 'put')
    # Short legs positive theta (we collect), long legs negative
    return (-sc_theta) + lc_theta + (-sp_theta) + lp_theta

Time-Based Exit Rules

Experienced traders exit iron condors well before expiration to avoid gamma risk. A well-tuned agent should implement these DTE-based exits:

DTE RemainingActionRationale
21 DTEClose if 50% profit achievedCapture majority of theta; avoid gamma spike
15 DTEClose at 25% profit if still openGamma risk outweighs remaining theta
7 DTEClose immediately regardless of P&LPin risk and gap risk become unmanageable
Any DTEClose at 200% of credit received (loss)Hard max-loss rule to prevent runaway loss

4. IV Crush Exploitation: Entry Signal Engineering

Implied volatility rank (IVR) is the most important entry signal for iron condors. When IV is elevated — meaning options are expensive — more premium can be collected for the same strike distance. After the catalyst (earnings, economic event) passes, IV collapses, dramatically reducing the option values even if price barely moved.

IV Rank Calculation

IVR compares current IV to its 52-week high and low:

def iv_rank(current_iv: float, iv_52w_high: float, iv_52w_low: float) -> float:
    """Returns 0-100. Above 50 = elevated IV, favorable for selling."""
    if iv_52w_high == iv_52w_low:
        return 50.0
    return (current_iv - iv_52w_low) / (iv_52w_high - iv_52w_low) * 100

def iv_percentile(current_iv: float, historical_ivs: list) -> float:
    """Percentage of past readings below current IV (more robust than IVR)."""
    return sum(1 for iv in historical_ivs if iv < current_iv) / len(historical_ivs) * 100

# Entry filter: only sell iron condors when IV is elevated
def should_enter_condor(ivr: float, dte: int, vix: float) -> bool:
    return (
        ivr >= 50           # IV elevated relative to recent history
        and 30 <= dte <= 50  # optimal theta-to-gamma window
        and vix < 35         # avoid entering during extreme panic
    )

IV Crush Trade Examples

The classic IV crush play is placing an iron condor before earnings, then closing shortly after the announcement regardless of price action. Even if the stock moves significantly, the collapse in IV (often 40-60%) can make the position profitable.

IV Crush Scenario

Pre-earnings: IV = 85%. Entry credit = $3.20 on a $10-wide condor. Post-earnings: IV = 45%, stock moved 3%. Position value drops to $1.80. Close for $1.40 profit (44% of max) in under 24 hours.

5. Rolling and Adjusting Positions

Rolling means closing an existing leg (or full spread) and re-opening it at a different strike or expiration. For iron condors, rolling is the primary defensive mechanism when price threatens a short strike.

Roll Types and When to Use Each

class CondorAdjuster:
    def __init__(self, broker_client, position):
        self.broker = broker_client
        self.position = position

    async def roll_tested_side(self, side: str, new_short_strike: float, new_long_strike: float, expiry: str):
        """Close breached spread, open new spread further OTM."""
        if side == 'call':
            # Buy back short call, sell long call (close bear call spread)
            await self.broker.close_spread(
                self.position.short_call, self.position.long_call, 'call'
            )
            # Open new bear call spread further OTM
            credit = await self.broker.open_vertical(
                short_strike=new_short_strike,
                long_strike=new_long_strike,
                option_type='call',
                expiry=expiry,
                action='sell'
            )
        elif side == 'put':
            await self.broker.close_spread(
                self.position.short_put, self.position.long_put, 'put'
            )
            credit = await self.broker.open_vertical(
                short_strike=new_short_strike,
                long_strike=new_long_strike,
                option_type='put',
                expiry=expiry,
                action='sell'
            )
        return {'additional_credit': credit, 'new_position': 'updated'}

    def should_roll(self, underlying_price: float, current_iv: float, dte: int) -> dict:
        """Determine if and how to roll the position."""
        sc_dist = self.position.short_call - underlying_price
        sp_dist = underlying_price - self.position.short_put

        if sc_dist < 0:    # price above short call
            return {'action': 'roll', 'side': 'call', 'urgency': 'high'}
        elif sp_dist < 0: # price below short put
            return {'action': 'roll', 'side': 'put', 'urgency': 'high'}
        elif sc_dist / underlying_price < 0.02:  # within 2% of short call
            return {'action': 'monitor', 'side': 'call', 'urgency': 'medium'}
        elif sp_dist / underlying_price < 0.02:
            return {'action': 'monitor', 'side': 'put', 'urgency': 'medium'}
        return {'action': 'hold', 'urgency': 'none'}

6. Full IronCondorAgent Implementation

The following is a complete autonomous agent that scans for IV rank signals, selects strikes, manages the lifecycle of iron condor positions, and executes rolls when necessary. It integrates with Purple Flea's trading infrastructure at trading.purpleflea.com.

import asyncio
import logging
from datetime import datetime, timedelta
from typing import Optional, List
import httpx

# Purple Flea Trading API configuration
PF_TRADING_URL = "https://trading.purpleflea.com"
PF_WALLET_URL = "https://wallet.purpleflea.com"

class IronCondorAgent:
    """
    Autonomous agent that runs a theta-harvesting iron condor strategy.
    Entry: IVR >= 50, DTE 30-45
    Management: Delta threshold, 50% profit target, 21-DTE exit
    Exit: 50% profit, max loss at 200% credit, or 21 DTE
    """

    def __init__(self, api_key: str, wallet_address: str, max_position_size: float = 5000.0):
        self.api_key = api_key
        self.wallet = wallet_address
        self.max_size = max_position_size
        self.positions = {}
        self.logger = logging.getLogger('IronCondorAgent')
        self.client = httpx.AsyncClient(
            headers={'Authorization': f'Bearer {api_key}'},
            timeout=30
        )

    async def scan_for_entries(self, symbols: List[str]) -> list:
        """Find symbols with elevated IV suitable for condor entry."""
        candidates = []
        for symbol in symbols:
            resp = await self.client.get(f'{PF_TRADING_URL}/iv-data/{symbol}')
            data = resp.json()

            ivr = iv_rank(data['current_iv'], data['iv_52w_high'], data['iv_52w_low'])
            dte = data['nearest_monthly_dte']
            vix = data['vix']

            if should_enter_condor(ivr, dte, vix):
                candidates.append({
                    'symbol': symbol, 'ivr': ivr,
                    'dte': dte, 'price': data['underlying_price'],
                    'iv': data['current_iv']
                })
        return sorted(candidates, key=lambda x: x['ivr'], reverse=True)

    async def select_strikes(self, symbol: str, price: float, iv: float, dte: int) -> dict:
        """Select strikes targeting ~0.16 delta on short legs, $5-wide wings."""
        T = dte / 365
        sigma = iv
        one_std_move = price * sigma * (T ** 0.5)

        # Short strikes at approximately 1 std dev OTM
        short_call = round(price + one_std_move, 0)
        short_put = round(price - one_std_move, 0)

        # Long strikes $5 further OTM (wing width)
        long_call = short_call + 5
        long_put = short_put - 5

        return {
            'short_call': short_call, 'long_call': long_call,
            'short_put': short_put, 'long_put': long_put,
            'wing_width': 5, 'one_std_move': one_std_move
        }

    async def open_condor(self, symbol: str, strikes: dict, expiry: str) -> dict:
        """Submit all four legs as a combo order."""
        order = {
            'symbol': symbol,
            'strategy': 'iron_condor',
            'expiry': expiry,
            'legs': [
                {'strike': strikes['short_call'], 'type': 'call', 'action': 'sell', 'qty': 1},
                {'strike': strikes['long_call'],  'type': 'call', 'action': 'buy',  'qty': 1},
                {'strike': strikes['short_put'], 'type': 'put',  'action': 'sell', 'qty': 1},
                {'strike': strikes['long_put'],  'type': 'put',  'action': 'buy',  'qty': 1},
            ],
            'order_type': 'limit',
            'limit_price': 'mid'   # always try to get mid-market fill
        }
        resp = await self.client.post(f'{PF_TRADING_URL}/orders/combo', json=order)
        result = resp.json()
        self.positions[result['order_id']] = {**strikes, **result, 'symbol': symbol}
        return result

    async def manage_positions(self):
        """Main management loop — run every 5 minutes during market hours."""
        for pos_id, pos in list(self.positions.items()):
            data = await self.client.get(f'{PF_TRADING_URL}/positions/{pos_id}')
            p = data.json()

            pnl_pct = p['unrealized_pnl'] / p['max_profit']
            dte = p['dte']

            # Exit rules
            if pnl_pct >= 0.50 and dte <= 21:
                await self.close_position(pos_id, reason="50pct profit target")
            elif p['unrealized_pnl'] <= -p['max_profit'] * 2:
                await self.close_position(pos_id, reason="max loss 200pct")
            elif dte <= 7:
                await self.close_position(pos_id, reason="7 DTE hard exit")
            else:
                # Delta check
                monitor = IronCondorMonitor()
                if monitor.needs_adjustment():
                    direction = monitor.adjustment_direction()
                    self.logger.info(ff'Adjustment needed: {direction}')

    async def close_position(self, pos_id: str, reason: str = ""):
        self.logger.info(ff'Closing {pos_id}: {reason}')
        await self.client.post(ff'{PF_TRADING_URL}/positions/{pos_id}/close')
        del self.positions[pos_id]

    async def run(self, symbols: List[str], poll_seconds: int = 300):
        self.logger.info("IronCondorAgent starting...")
        while True:
            try:
                # Find new entries if under position limit
                if len(self.positions) < 5:
                    candidates = await self.scan_for_entries(symbols)
                    if candidates:
                        best = candidates[0]
                        strikes = await self.select_strikes(
                            best['symbol'], best['price'],
                            best['iv'], best['dte']
                        )
                        expiry = (datetime.now() + timedelta(days=best['dte'])).strftime('%Y-%m-%d')
                        await self.open_condor(best['symbol'], strikes, expiry)

                # Manage existing positions
                await self.manage_positions()

            except Exception as e:
                self.logger.error(ff'Error in main loop: {e}')

            await asyncio.sleep(poll_seconds)

7. Risk Management and Position Sizing

Iron condors carry defined risk but that risk must still be carefully allocated. A single bad position can wipe out months of theta gains if sizing is not disciplined.

Kelly Criterion for Iron Condors

Adapting the Kelly formula for defined-risk credit strategies:

def kelly_fraction_condor(
    prob_profit: float,      # e.g. 0.68
    credit_received: float,  # e.g. $2.50
    max_loss: float,          # e.g. $7.50 (wing width - credit)
    kelly_fraction: float = 0.25  # fractional Kelly for safety
) -> float:
    """Returns fraction of account to risk per condor."""
    prob_loss = 1 - prob_profit
    # Kelly: f = (p * b - q) / b where b = win/loss ratio
    b = credit_received / max_loss
    kelly = (prob_profit * b - prob_loss) / b
    return max(0, kelly * kelly_fraction)

# Example: 68% PoP, collect $2.50 on $7.50 max loss
f = kelly_fraction_condor(0.68, 2.50, 7.50)
# f ≈ 0.057 → risk ~5.7% of account per condor

Correlation Risk Across Multiple Condors

Running condors on correlated underlying assets compounds tail risk. If the S&P drops 5% in one day, all equity condors breach simultaneously. An agent must track sector concentration and cap correlated exposure.

Concentration Warning

Never run more than 2-3 condors on assets from the same sector simultaneously. VIX spikes and macro events create correlated breaches that no individual risk rule can protect against.

8. Performance Benchmarking and Monitoring

An agent must maintain a live performance dashboard to validate that the strategy remains profitable over rolling windows, not just individual trades.

class CondorPerformanceTracker:
    def __init__(self):
        self.trades = []

    def record_trade(self, credit: float, pnl: float, max_profit: float, max_loss: float, dte_at_entry: int):
        self.trades.append({
            'credit': credit, 'pnl': pnl,
            'max_profit': max_profit, 'max_loss': max_loss,
            'dte': dte_at_entry, 'won': pnl > 0
        })

    def summary(self) -> dict:
        if not self.trades:
            return {}
        wins = [t for t in self.trades if t['won']]
        losses = [t for t in self.trades if not t['won']]
        total_pnl = sum(t['pnl'] for t in self.trades)
        return {
            'total_trades': len(self.trades),
            'win_rate': len(wins) / len(self.trades),
            'avg_win': sum(t['pnl'] for t in wins) / (len(wins) or 1),
            'avg_loss': sum(t['pnl'] for t in losses) / (len(losses) or 1),
            'total_pnl': total_pnl,
            'expectancy': total_pnl / len(self.trades)
        }

9. Backtesting Framework for Iron Condor Agents

Before deploying live capital, every iron condor agent should be validated against historical data. A proper backtest simulates realistic entry conditions, bid-ask spreads, and management actions rather than just checking theoretical payoffs.

Realistic Backtest Assumptions

class IronCondorBacktester:
    def __init__(self, price_history: pd.DataFrame, iv_history: pd.DataFrame):
        self.prices = price_history
        self.ivs = iv_history
        self.results = []
        self.slippage = 0.15        # 15% worse than mid
        self.commission_per_leg = 0.65

    def run(self, ivr_threshold: float = 50, target_dte: int = 40,
             profit_target_pct: float = 0.50, stop_pct: float = 2.0):
        """Iterate over historical data, simulate condor entries and exits."""
        for i in range(60, len(self.prices)):
            row = self.prices.iloc[i]
            iv_row = self.ivs.iloc[i]
            ivr = iv_rank(iv_row['atm_iv'], iv_row['iv_52w_high'], iv_row['iv_52w_low'])

            if ivr < ivr_threshold:
                continue   # no entry signal

            S = row['close']
            sigma = iv_row['atm_iv']
            T = target_dte / 365
            one_std = S * sigma * (T ** 0.5)

            sc = round(S + one_std)
            lc = sc + 5
            sp = round(S - one_std)
            lp = sp - 5

            # Estimate entry credit (simplified BS)
            sc_prem = max(0.01, S * sigma * (T**0.5) * 0.10)
            sp_prem = max(0.01, S * sigma * (T**0.5) * 0.10)
            credit = (sc_prem + sp_prem) * (1 - self.slippage)
            commission = self.commission_per_leg * 4
            net_credit = credit - commission / 100
            max_loss = 5 - net_credit

            # Simulate forward price path over DTE days
            end_idx = min(i + target_dte, len(self.prices) - 1)
            outcome_price = self.prices.iloc[end_idx]['close']

            if sp <= outcome_price <= sc:
                pnl = net_credit   # full profit
                result = 'max_profit'
            elif outcome_price > sc + net_credit:
                pnl = -max_loss
                result = 'max_loss_call'
            elif outcome_price < sp - net_credit:
                pnl = -max_loss
                result = 'max_loss_put'
            else:
                # Partial profit/loss in breakeven zone
                pnl = net_credit - max(0, abs(outcome_price - sc) - net_credit,
                                         abs(outcome_price - sp) - net_credit)
                result = 'partial'

            self.results.append({
                'entry_date': row.get('date', i), 'entry_price': S,
                'ivr': ivr, 'net_credit': net_credit,
                'pnl': pnl, 'result': result
            })

    def summary(self) -> dict:
        if not self.results:
            return {}
        total = len(self.results)
        wins = [r for r in self.results if r['pnl'] > 0]
        total_pnl = sum(r['pnl'] for r in self.results)
        return {
            'total_trades': total,
            'win_rate': len(wins) / total,
            'expectancy_per_trade': total_pnl / total,
            'total_pnl': total_pnl,
            'avg_win': sum(r['pnl'] for r in wins) / (len(wins) or 1)
        }

10. Multi-Asset Portfolio of Iron Condors

Running a single iron condor at a time leaves capital idle between cycles. A portfolio approach runs multiple condors simultaneously across uncorrelated assets, smoothing returns and improving capital utilization.

Portfolio Construction Rules

Portfolio Expectancy

A diversified portfolio of 5 iron condors across uncorrelated assets, with an average 65% win rate and 2:1 loss-to-win ratio, produces a positive expectancy of approximately $0.18 per dollar of max risk deployed per cycle — before compounding. Over 12-15 cycles per year per symbol, the math favors systematic deployment.

Deploy Your Iron Condor Agent

Access 275+ perpetual markets for options-like volatility plays. Fund your agent wallet, register, and start collecting theta decay systematically.