Options Selling Strategies for AI Agents:
Theta Decay Income
How autonomous agents systematically collect premium income by selling options, managing delta exposure, timing entries with IV rank, and rolling positions with disciplined risk controls.
1. Theta Decay Mechanics
Every options contract has a time value component that decays toward zero as expiration approaches. Theta measures this decay rate — for option sellers, it represents daily income collected simply by holding a short position. AI agents are ideally suited to exploit theta decay because the strategy requires consistent, systematic execution rather than directional prediction.
The Theta Decay Curve
Theta decay is non-linear. It accelerates sharply in the final 30 days before expiration, creating the characteristic “hockey stick” decay curve that options sellers target. An option with 60 days to expiration (DTE) might lose 0.05% of its value per day, while the same option at 10 DTE loses 0.5% per day.
Where:
S = underlying price
sigma = implied volatility (annualized)
T = time to expiration (in years)
K = strike price
r = risk-free rate
N'(d1) = standard normal PDF at d1
For practical purposes, agents use a simplified daily theta estimate: theta ≈ option_price * decay_rate, where decay_rate accelerates as DTE shrinks. The sweet spot for sellers is entering at 30-45 DTE and closing at 50% profit or 21 DTE — capturing the steepest part of the decay curve.
Extrinsic vs. Intrinsic Value
Only the extrinsic (time) value of an option decays. Intrinsic value — the amount an option is in-the-money — does not decay. This is why option sellers prefer out-of-the-money (OTM) strikes: 100% of the premium received is extrinsic and will decay to zero if the underlying stays OTM.
| Moneyness | Intrinsic Value | Extrinsic Value | Seller Preference |
|---|---|---|---|
| Deep ITM | High | Low | Avoid |
| ATM | Zero | Maximum | Caution |
| 25-delta OTM | Zero | High | Ideal |
| 10-delta OTM | Zero | Low | Low premium |
import numpy as np from scipy.stats import norm from dataclasses import dataclass from typing import Tuple @dataclass class OptionGreeks: delta: float gamma: float theta: float # per day vega: float # per 1% IV move price: float intrinsic: float extrinsic: float class BlackScholes: """Black-Scholes option pricing and Greeks.""" @staticmethod def d1(S: float, K: float, T: float, r: float, sigma: float) -> float: return (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) @staticmethod def d2(S: float, K: float, T: float, r: float, sigma: float) -> float: d1 = BlackScholes.d1(S, K, T, r, sigma) return d1 - sigma * np.sqrt(T) @classmethod def call_price(cls, S: float, K: float, T: float, r: float, sigma: float) -> float: d1 = cls.d1(S, K, T, r, sigma) d2 = cls.d2(S, K, T, r, sigma) return S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2) @classmethod def put_price(cls, S: float, K: float, T: float, r: float, sigma: float) -> float: d1 = cls.d1(S, K, T, r, sigma) d2 = cls.d2(S, K, T, r, sigma) return K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1) @classmethod def greeks(cls, S: float, K: float, T: float, r: float, sigma: float, option_type: str = 'put') -> OptionGreeks: if T <= 0: raise ValueError("T must be positive") d1 = cls.d1(S, K, T, r, sigma) d2 = cls.d2(S, K, T, r, sigma) n_d1 = norm.pdf(d1) # standard normal PDF # Delta if option_type == 'call': delta = norm.cdf(d1) price = cls.call_price(S, K, T, r, sigma) intrinsic = max(0, S - K) else: delta = norm.cdf(d1) - 1 # negative for puts price = cls.put_price(S, K, T, r, sigma) intrinsic = max(0, K - S) # Gamma (same for calls and puts) gamma = n_d1 / (S * sigma * np.sqrt(T)) # Theta per calendar day theta_annual = -(S * n_d1 * sigma) / (2 * np.sqrt(T)) if option_type == 'call': theta_annual -= r * K * np.exp(-r * T) * norm.cdf(d2) else: theta_annual += r * K * np.exp(-r * T) * norm.cdf(-d2) theta_daily = theta_annual / 365 # Vega per 1% move in IV vega = S * n_d1 * np.sqrt(T) / 100 extrinsic = price - intrinsic return OptionGreeks( delta=round(delta, 4), gamma=round(gamma, 6), theta=round(theta_daily, 4), vega=round(vega, 4), price=round(price, 4), intrinsic=round(intrinsic, 4), extrinsic=round(extrinsic, 4) ) def model_theta_decay_curve(S: float, K: float, sigma: float, r: float = 0.05) -> list: """Model how option value decays from 45 DTE to expiration.""" decay_curve = [] bs = BlackScholes() for dte in range(45, 0, -1): T = dte / 365 greeks = bs.greeks(S, K, T, r, sigma, 'put') decay_curve.append({ 'dte': dte, 'price': greeks.price, 'theta': greeks.theta, 'delta': greeks.delta, 'theta_pct': abs(greeks.theta / greeks.price) * 100 }) return decay_curve # Example: BTC at $65,000, sell 60,000 put, 35 DTE, IV=70% if __name__ == '__main__': bs = BlackScholes() greeks = bs.greeks( S=65000, K=60000, T=35/365, r=0.05, sigma=0.70, option_type='put' ) print(f"Option price: ${greeks.price:.2f}") print(f"Daily theta: ${greeks.theta:.2f} ({abs(greeks.theta/greeks.price)*100:.2f}%/day)") print(f"Delta: {greeks.delta:.4f} (${abs(greeks.delta)*65000:.0f} equiv exposure)") print(f"Extrinsic: ${greeks.extrinsic:.2f} (100% time value)")
2. Core Selling Strategies
Options sellers have two primary income-generating strategies: covered calls (selling upside in an asset you own) and cash-secured puts (agreeing to buy an asset at a lower price). Both collect premium upfront; the premium retained is the profit if expiration occurs OTM.
Covered Calls
A covered call involves owning the underlying asset and selling a call option against it. The agent receives premium income while capping upside at the strike price. Risk is limited to the downside of the underlying (offset by premium received).
Max profit = Premium received + (Strike - Entry price)
Max loss = Entry price - Premium received (same as holding the asset)
Breakeven = Entry price - Premium received
Cash-Secured Puts
A cash-secured put involves selling a put option while holding enough cash to buy the underlying at the strike price if assigned. The agent earns premium while waiting to acquire the asset at a discount. This is the more common strategy for agents without existing positions.
Max profit = Premium_received (if expires OTM)
Max loss = Strike - Premium_received (underlying goes to zero)
Breakeven = Strike - Premium_received
Annualized return = (Premium / Strike) * (365 / DTE) * 100%
Premium Targeting Formula
Agents should target a minimum premium that justifies the risk and collateral tied up. A common rule is targeting at least 1% of the strike price per month in premium for 30-DTE options, scaling with IV conditions.
from dataclasses import dataclass, field from typing import Optional, List from enum import Enum import numpy as np class OptionStrategy(Enum): COVERED_CALL = "covered_call" CASH_SECURED_PUT = "cash_secured_put" SHORT_STRANGLE = "short_strangle" JADE_LIZARD = "jade_lizard" @dataclass class StrategySetup: strategy: OptionStrategy underlying: str underlying_price: float strike: float expiration_dte: int premium_received: float collateral_required: float max_profit: float max_loss: float breakeven: float annualized_return_pct: float prob_profit: float # probability of expiring OTM class StrategyBuilder: """Build and evaluate options selling strategies.""" def __init__(self, bs_pricer: 'BlackScholes'): self.bs = bs_pricer def build_csp( self, underlying: str, S: float, K: float, dte: int, iv: float, r: float = 0.05 ) -> StrategySetup: """Cash-secured put setup.""" T = dte / 365 greeks = self.bs.greeks(S, K, T, r, iv, 'put') premium = greeks.price # Collateral = full strike (cash to buy at strike if assigned) collateral = K # P&L bounds max_profit = premium max_loss = K - premium # if underlying goes to 0 breakeven = K - premium # Annualized return on collateral ann_return = (premium / collateral) * (365 / dte) * 100 # Probability of profit ≈ probability of expiring OTM (above strike) # = N(d2) for puts in BS framework from scipy.stats import norm d1 = self.bs.d1(S, K, T, r, iv) d2 = d1 - iv * np.sqrt(T) prob_otm = norm.cdf(d2) # probability put expires worthless return StrategySetup( strategy=OptionStrategy.CASH_SECURED_PUT, underlying=underlying, underlying_price=S, strike=K, expiration_dte=dte, premium_received=round(premium, 2), collateral_required=round(collateral, 2), max_profit=round(max_profit, 2), max_loss=round(max_loss, 2), breakeven=round(breakeven, 2), annualized_return_pct=round(ann_return, 2), prob_profit=round(prob_otm, 4) ) def evaluate_minimum_premium( self, setup: StrategySetup, min_monthly_pct: float = 1.0, # 1% of strike per month min_prob_profit: float = 0.60 # 60% probability of profit ) -> dict: """Check if trade meets minimum premium and probability criteria.""" monthly_premium_pct = (setup.premium_received / setup.strike) * (30 / setup.expiration_dte) * 100 meets_premium = monthly_premium_pct >= min_monthly_pct meets_prob = setup.prob_profit >= min_prob_profit return { 'trade_ok': meets_premium and meets_prob, 'monthly_premium_pct': round(monthly_premium_pct, 3), 'prob_profit': setup.prob_profit, 'annualized_return': setup.annualized_return_pct, 'meets_premium_threshold': meets_premium, 'meets_prob_threshold': meets_prob, 'reason': 'PASS' if (meets_premium and meets_prob) else ( 'Low premium' if not meets_premium else 'Low probability' ) }
3. IV Rank and IV Percentile for Timing Entries
Options sellers thrive when implied volatility (IV) is elevated relative to its historical range. Selling options when IV is high means collecting more premium for the same strike distance. Two metrics guide entry timing: IV Rank and IV Percentile.
IV Rank (IVR)
IV Rank measures where current IV sits within its 52-week high/low range. An IVR of 80 means current IV is in the top 20% of its annual range. Option sellers typically wait for IVR above 50 before entering positions.
IVR = 0 → IV at 52-week low (bad time to sell)
IVR = 50 → IV at midpoint (acceptable)
IVR = 100 → IV at 52-week high (ideal for sellers)
IV Percentile (IVP)
IV Percentile measures what percentage of past daily IV readings were below the current IV. IVP of 75 means IV has been lower than today 75% of the time. IVP is generally more statistically robust than IVR because it is not skewed by a single extreme reading.
IVR > 50 AND IVP > 50: Strong entry signal — both metrics confirm elevated IV.
IVR > 70: High conviction entry — IV near annual highs, maximum premium collection.
IVR < 30: Avoid selling — insufficient premium relative to risk taken.
Implied Volatility vs. Realized Volatility
The volatility risk premium is the persistent tendency for implied volatility to exceed realized (historical) volatility. On average, IV runs 3-5 percentage points above actual volatility. Option sellers are compensated for providing liquidity and taking on tail risk — this premium is the structural edge of systematic selling.
import numpy as np from typing import List, Optional, Tuple from dataclasses import dataclass import statistics @dataclass class IVSnapshot: date: str iv_30d: float # 30-day implied vol (annualized %) iv_60d: float hv_30d: float # realized vol past 30 days @dataclass class IVAnalysis: current_iv: float iv_rank: float # 0-100 iv_percentile: float # 0-100 hv_30d: float vol_premium: float # IV - HV (the edge) entry_signal: str # STRONG / MODERATE / WEAK / AVOID recommended_delta: float class IVAnalyzer: """Analyze implied volatility conditions for options entry timing.""" LOOKBACK_DAYS = 252 # trading days in a year def __init__(self, iv_history: List[IVSnapshot]): """ iv_history: list of daily IV snapshots, most recent last. At least 252 entries recommended for reliable IVR/IVP. """ self.history = iv_history[-self.LOOKBACK_DAYS:] self.iv_series = [s.iv_30d for s in self.history] def compute_iv_rank(self, current_iv: float) -> float: """IV Rank: position within 52-week high/low range.""" iv_high = max(self.iv_series) iv_low = min(self.iv_series) if iv_high == iv_low: return 50.0 ivr = (current_iv - iv_low) / (iv_high - iv_low) * 100 return round(max(0, min(100, ivr)), 1) def compute_iv_percentile(self, current_iv: float) -> float: """IV Percentile: % of past readings below current IV.""" below = sum(1 for iv in self.iv_series if iv < current_iv) ivp = (below / len(self.iv_series)) * 100 return round(ivp, 1) def suggest_delta(self, ivr: float, ivp: float) -> float: """ Higher IV → can sell further OTM (lower delta) and still collect enough premium. Lower IV → must go closer to ATM (higher delta) for adequate premium. """ avg_score = (ivr + ivp) / 2 if avg_score >= 75: return 0.16 # sell very far OTM, high IV provides enough premium elif avg_score >= 50: return 0.25 # standard 25-delta target elif avg_score >= 30: return 0.30 # slightly closer to ATM else: return 0.35 # close to ATM; low IV environment def entry_signal(self, ivr: float, ivp: float, vol_premium: float) -> str: if ivr >= 70 and ivp >= 60 and vol_premium > 5: return "STRONG" elif ivr >= 50 and ivp >= 50: return "MODERATE" elif ivr >= 30 and ivp >= 40: return "WEAK" else: return "AVOID" def analyze(self, current_iv: float, current_hv: float) -> IVAnalysis: """Full IV analysis for entry timing decision.""" ivr = self.compute_iv_rank(current_iv) ivp = self.compute_iv_percentile(current_iv) vol_premium = current_iv - current_hv signal = self.entry_signal(ivr, ivp, vol_premium) rec_delta = self.suggest_delta(ivr, ivp) return IVAnalysis( current_iv=current_iv, iv_rank=ivr, iv_percentile=ivp, hv_30d=current_hv, vol_premium=round(vol_premium, 2), entry_signal=signal, recommended_delta=rec_delta )
4. Delta Management
Short options positions carry directional risk measured by delta. As the underlying price moves, delta changes and the agent's net exposure shifts. Active delta management prevents positions from becoming excessively directional and protects against large adverse moves.
Delta Targets for Short Puts
The most commonly sold puts target a 25-delta at entry. This means the put has approximately a 25% chance of expiring in-the-money (and a 75% chance of expiring worthless). More aggressive sellers use 30-35 delta for higher premium; conservative sellers use 15-20 delta.
Gamma Risk Near Expiration
As expiration approaches, gamma — the rate of change of delta — increases dramatically. A 25-delta put at 30 DTE might have a gamma of 0.003, but the same strike at 5 DTE could have a gamma of 0.015. This means delta changes 5x faster, and positions can go from safe to deeply ITM very quickly. Agents should close or roll positions before entering this danger zone.
Never hold short options with less than 7 DTE unless intentionally targeting expiration. Gamma risk in the final week is extreme — a 5% adverse move can turn a profitable position into maximum loss.
Portfolio Delta Limits
A portfolio-level delta limit prevents the agent from becoming too directional in aggregate. All short put positions contribute negative delta (bearish if underlying falls). The agent should track total portfolio delta and limit it to an acceptable exposure.
from dataclasses import dataclass, field from typing import List, Dict, Optional import numpy as np @dataclass class ShortPutPosition: id: str underlying: str underlying_price: float strike: float dte: int contracts: int # number of contracts (1 contract = 1 unit in crypto) delta: float # current delta (negative for short put) entry_premium: float current_price: float entry_iv: float current_iv: float @property def dollar_delta(self) -> float: """Dollar delta = delta * contracts * underlying_price.""" return self.delta * self.contracts * self.underlying_price @property def unrealized_pnl(self) -> float: """P&L for short position: positive when option decays.""" return (self.entry_premium - self.current_price) * self.contracts @property def pct_profit(self) -> float: """Percentage of max profit captured.""" return (self.entry_premium - self.current_price) / self.entry_premium * 100 class DeltaManager: """Track and manage portfolio-level delta for short options positions.""" def __init__( self, max_portfolio_delta: float = -0.10, # max -10% of portfolio as delta max_single_delta: float = -0.03, # max -3% per position portfolio_value: float = 100000 ): self.max_portfolio_delta = max_portfolio_delta self.max_single_delta = max_single_delta self.portfolio_value = portfolio_value self.positions: List[ShortPutPosition] = [] def add_position(self, pos: ShortPutPosition) -> dict: """Check if adding position would breach delta limits.""" new_dollar_delta = pos.dollar_delta current_total = self.total_dollar_delta() projected_total = current_total + new_dollar_delta projected_pct = projected_total / self.portfolio_value if projected_pct < self.max_portfolio_delta: return { 'approved': False, 'reason': f'Portfolio delta would be {projected_pct:.2%}, limit {self.max_portfolio_delta:.2%}' } single_pct = new_dollar_delta / self.portfolio_value if single_pct < self.max_single_delta: return { 'approved': False, 'reason': f'Single position delta {single_pct:.2%} exceeds limit {self.max_single_delta:.2%}' } self.positions.append(pos) return { 'approved': True, 'portfolio_delta_pct': round(projected_pct * 100, 2), 'position_count': len(self.positions) } def total_dollar_delta(self) -> float: return sum(p.dollar_delta for p in self.positions) def positions_to_close( self, profit_target_pct: float = 50, dte_limit: int = 21, loss_limit_pct: float = 200 # close at 2x premium received (200% loss) ) -> List[dict]: """Identify positions that should be closed per management rules.""" to_close = [] for pos in self.positions: reasons = [] # Profit target: close at 50% of max profit if pos.pct_profit >= profit_target_pct: reasons.append(f'Profit target reached: {pos.pct_profit:.1f}%') # Time-based: close at 21 DTE to avoid gamma risk if pos.dte <= dte_limit: reasons.append(f'DTE limit reached: {pos.dte} DTE') # Loss limit: close if option price is >200% of entry (avoid disaster) loss_pct = (pos.current_price - pos.entry_premium) / pos.entry_premium * 100 if loss_pct >= loss_limit_pct: reasons.append(f'Loss limit reached: {loss_pct:.0f}% of premium') if reasons: to_close.append({ 'position_id': pos.id, 'reasons': reasons, 'unrealized_pnl': pos.unrealized_pnl, 'action': 'CLOSE' }) return to_close def portfolio_summary(self) -> dict: total_pnl = sum(p.unrealized_pnl for p in self.positions) total_premium = sum(p.entry_premium * p.contracts for p in self.positions) total_delta = self.total_dollar_delta() return { 'open_positions': len(self.positions), 'total_unrealized_pnl': round(total_pnl, 2), 'total_premium_collected': round(total_premium, 2), 'portfolio_dollar_delta': round(total_delta, 2), 'portfolio_delta_pct': round(total_delta / self.portfolio_value * 100, 2), 'avg_dte': round(np.mean([p.dte for p in self.positions]), 1) if self.positions else 0 }
5. Rolling Positions
Rolling is the process of closing an existing position and simultaneously opening a new one — either to extend duration (roll out in time), move the strike (roll up or down), or both. Rolling is a critical skill for agents managing threatened or expiring positions.
When to Roll
- Roll out for more time: Position has reached 21 DTE but hasn’t hit the profit target. Close and reopen 30-45 DTE for a credit.
- Roll down and out: Underlying has fallen through the strike. Roll to a lower strike and further out in time to collect additional credit while reducing strike exposure.
- Roll up at expiration: Position expired profitably. Immediately reopen the next cycle.
Rolling for a Net Credit
A fundamental rule: only roll for a net credit. The closing debit should be less than the opening credit received on the new position. Rolling for a debit digs a deeper hole and often just delays eventual losses.
Example:
Current short put: trading at $500 (entered at $800)
New 30-DTE put at lower strike: priced at $650
Net credit = $650 - $500 = $150 (acceptable roll)
If net credit is negative: do NOT roll; take the loss or wait
Mechanical Rolling Rules for Agents
Automated rolling decisions prevent emotional overrides. The agent should evaluate roll candidates against a strict ruleset and only execute when all criteria are met.
from dataclasses import dataclass from typing import Optional, List, Tuple from enum import Enum class RollAction(Enum): ROLL_OUT = "roll_out" # same strike, more DTE ROLL_DOWN_OUT = "roll_down_out" # lower strike, more DTE TAKE_LOSS = "take_loss" # close without rolling HOLD = "hold" # no action needed yet TAKE_PROFIT = "take_profit" # close for profit @dataclass class RollDecision: action: RollAction reason: str new_strike: Optional[float] = None new_dte: Optional[int] = None estimated_credit: float = 0.0 class PositionRoller: """Evaluate and execute systematic rolling decisions.""" def __init__( self, profit_target_pct: float = 50, # take profit at 50% loss_threshold_pct: float = 200, # roll or close at 200% loss min_dte_before_roll: int = 21, target_new_dte: int = 35, roll_credit_requirement: float = 0.0 # must roll for >= this credit ): self.profit_target_pct = profit_target_pct self.loss_threshold_pct = loss_threshold_pct self.min_dte_before_roll = min_dte_before_roll self.target_new_dte = target_new_dte self.roll_credit_requirement = roll_credit_requirement def evaluate(self, pos: 'ShortPutPosition', new_option_price: float, new_strike: float) -> RollDecision: """ Evaluate whether and how to roll a position. new_option_price: premium available for the replacement position new_strike: proposed strike for the new position """ current_pct_profit = pos.pct_profit loss_pct = (pos.current_price - pos.entry_premium) / pos.entry_premium * 100 buyback_cost = pos.current_price roll_credit = new_option_price - buyback_cost # 1. Take profit: position has decayed enough if current_pct_profit >= self.profit_target_pct: return RollDecision( action=RollAction.TAKE_PROFIT, reason=f'Profit target reached: {current_pct_profit:.1f}% of max profit' ) # 2. Loss limit exceeded: evaluate roll or take loss if loss_pct >= self.loss_threshold_pct: if roll_credit > self.roll_credit_requirement: return RollDecision( action=RollAction.ROLL_DOWN_OUT, reason=f'Loss limit {loss_pct:.0f}% reached; rolling for ${roll_credit:.2f} credit', new_strike=new_strike, new_dte=self.target_new_dte, estimated_credit=roll_credit ) else: return RollDecision( action=RollAction.TAKE_LOSS, reason=f'Loss limit reached and no credit roll available; taking loss' ) # 3. DTE expired: roll out for more time if pos.dte <= self.min_dte_before_roll: if roll_credit > self.roll_credit_requirement: return RollDecision( action=RollAction.ROLL_OUT, reason=f'DTE at {pos.dte}, rolling out for ${roll_credit:.2f} credit', new_strike=pos.strike, # same strike new_dte=self.target_new_dte, estimated_credit=roll_credit ) else: return RollDecision( action=RollAction.TAKE_LOSS, reason=f'DTE at {pos.dte} with no credit roll available' ) # 4. No action needed return RollDecision( action=RollAction.HOLD, reason=f'Position healthy: {current_pct_profit:.1f}% profit, {pos.dte} DTE' ) def batch_evaluate( self, positions: List['ShortPutPosition'], market_data: dict # {pos_id: {'new_price': float, 'new_strike': float}} ) -> List[Tuple[str, RollDecision]]: """Evaluate all positions and return action list.""" decisions = [] for pos in positions: data = market_data.get(pos.id, {}) new_price = data.get('new_price', 0) new_strike = data.get('new_strike', pos.strike * 0.95) decision = self.evaluate(pos, new_price, new_strike) decisions.append((pos.id, decision)) return decisions
6. Risk Management and Max Loss
The asymmetric risk profile of options selling — limited upside (premium), potentially large downside — demands strict risk controls. Unlike directional traders, options sellers face the risk of tail events: sudden crashes that send options from OTM to deeply ITM within hours.
Assignment Risk
When a cash-secured put is assigned, the agent must purchase the underlying at the strike price regardless of current market price. In crypto, a coin can fall 50-80% in days. Assignment management requires agents to either accept the position (if bullish longer-term) or immediately sell the assigned position.
Position Sizing Rules
- Max 5% of portfolio per position: No single trade should risk more than 5% of total capital.
- Max 25% in options selling: Keep 75%+ in non-leveraged positions as buffer for assignments.
- Max 3 positions per asset: Avoid concentration in a single name.
- Correlation limits: BTC and ETH are highly correlated — treat as one asset class.
Allocate 1-2% of portfolio to cheap OTM put options (5-delta puts) as tail risk insurance. These lottery-ticket longs will offset catastrophic losses from the short put book during crashes.
Volatility Regime Adjustment
During high-volatility regimes (VIX equivalent above 80 for crypto), even OTM options can become ITM rapidly. Reduce position size by 50% in high-vol regimes and widen strikes. The extra premium does not compensate for dramatically higher assignment probability.
from dataclasses import dataclass from typing import List, Dict import numpy as np @dataclass class RiskLimits: max_position_pct: float = 0.05 # 5% per position max_options_book_pct: float = 0.25 # 25% of portfolio in options max_positions_per_asset: int = 3 max_portfolio_delta: float = -0.10 # -10% delta exposure high_vol_threshold: float = 80 # IV > 80% = high vol regime high_vol_size_mult: float = 0.5 # halve position size in high vol tail_hedge_allocation: float = 0.015 # 1.5% for tail hedges class OptionsRiskManager: """Comprehensive risk management for options selling portfolios.""" def __init__(self, portfolio_value: float, limits: RiskLimits = None): self.portfolio_value = portfolio_value self.limits = limits or RiskLimits() self.positions: List[ShortPutPosition] = [] self.tail_hedges: List[dict] = [] def compute_position_size( self, strike: float, current_iv: float, asset: str ) -> dict: """ Calculate safe position size given risk limits. Returns max contracts to sell. """ # Base max notional base_max = self.portfolio_value * self.limits.max_position_pct collateral_per_contract = strike # cash-secured: 100% collateral # Reduce size in high-vol regimes vol_adj = self.limits.high_vol_size_mult if current_iv > self.limits.high_vol_threshold else 1.0 adjusted_max = base_max * vol_adj # Check existing positions per asset asset_positions = [p for p in self.positions if p.underlying == asset] if len(asset_positions) >= self.limits.max_positions_per_asset: return { 'max_contracts': 0, 'reason': f'Max {self.limits.max_positions_per_asset} positions per asset reached' } # Check total options book size current_book = sum(p.strike * p.contracts for p in self.positions) max_book = self.portfolio_value * self.limits.max_options_book_pct available_book = max_book - current_book max_by_book = available_book / collateral_per_contract max_contracts = int(min(adjusted_max / collateral_per_contract, max_by_book)) return { 'max_contracts': max(0, max_contracts), 'collateral_required': max_contracts * collateral_per_contract, 'vol_adjustment_applied': vol_adj < 1.0, 'high_vol_regime': current_iv > self.limits.high_vol_threshold, 'reason': 'OK' if max_contracts > 0 else 'Insufficient capacity' } def stress_test(self, shock_pcts: List[float] = [-10, -20, -30, -50]) -> dict: """Simulate P&L under various market shocks.""" results = {} for shock in shock_pcts: total_loss = 0 for pos in self.positions: shocked_price = pos.underlying_price * (1 + shock / 100) option_loss = max(0, pos.strike - shocked_price) net_pnl = pos.entry_premium - option_loss total_loss += net_pnl * pos.contracts results[f'{shock}%_shock'] = { 'pnl': round(total_loss, 2), 'pnl_pct': round(total_loss / self.portfolio_value * 100, 2), 'survivable': total_loss > -self.portfolio_value * 0.20 } return results
7. Python OptionsSellingAgent
Bringing all the components together: a complete autonomous agent that monitors IV conditions, selects optimal strikes, manages existing positions, rolls as needed, and maintains risk limits throughout its operation cycle.
import asyncio import logging from dataclasses import dataclass, field from typing import List, Dict, Optional, Tuple from datetime import datetime, timedelta import numpy as np import httpx logger = logging.getLogger('options_agent') @dataclass class AgentConfig: portfolio_value: float = 100_000 # USDC target_monthly_income_pct: float = 2.0 # 2% per month target min_iv_rank: float = 50 min_iv_percentile: float = 50 target_dte_min: int = 30 target_dte_max: int = 45 target_delta: float = 0.25 profit_close_pct: float = 50 loss_close_pct: float = 200 dte_close_limit: int = 21 max_positions: int = 8 scan_assets: List[str] = field(default_factory=lambda: ['BTC', 'ETH', 'SOL', 'AVAX']) run_interval_minutes: int = 60 # check every hour purple_flea_api: str = 'https://trading.purpleflea.com/api/v1' class MarketDataClient: """Fetch live option chain and IV data from trading API.""" def __init__(self, api_url: str, api_key: str): self.api_url = api_url self.headers = {'Authorization': f'Bearer {api_key}'} async def get_option_chain(self, asset: str, dte_min: int, dte_max: int) -> list: """Fetch available option strikes for the target expiration window.""" async with httpx.AsyncClient() as client: resp = await client.get( f'{self.api_url}/options/chain', params={'asset': asset, 'dte_min': dte_min, 'dte_max': dte_max}, headers=self.headers, timeout=10 ) resp.raise_for_status() return resp.json()['strikes'] async def get_iv_data(self, asset: str) -> dict: """Fetch current IV, IV rank, and IV percentile.""" async with httpx.AsyncClient() as client: resp = await client.get( f'{self.api_url}/volatility/{asset}', headers=self.headers, timeout=10 ) resp.raise_for_status() return resp.json() async def get_price(self, asset: str) -> float: async with httpx.AsyncClient() as client: resp = await client.get( f'{self.api_url}/price/{asset}', headers=self.headers, timeout=10 ) resp.raise_for_status() return resp.json()['price'] class StrikeSelector: """Select optimal strike for put selling based on delta target.""" def __init__(self, bs: BlackScholes): self.bs = bs def find_target_strike( self, S: float, T: float, r: float, iv: float, target_delta: float = 0.25, available_strikes: List[float] = None ) -> Tuple[float, OptionGreeks]: """Find the strike closest to target delta.""" if available_strikes is None: # Generate strike grid if no chain provided available_strikes = [S * ((1 - i * 0.01)) for i in range(5, 40)] best_strike = None best_greeks = None min_delta_diff = float('inf') for K in available_strikes: if K >= S: # only OTM puts (strike below spot) continue try: greeks = self.bs.greeks(S, K, T, r, iv, 'put') # delta is negative for puts; compare absolute value delta_diff = abs(abs(greeks.delta) - target_delta) if delta_diff < min_delta_diff: min_delta_diff = delta_diff best_strike = K best_greeks = greeks except Exception: continue return best_strike, best_greeks class OptionsSellingAgent: """ Autonomous agent for systematic cash-secured put selling. Connects to Purple Flea trading infrastructure. """ def __init__(self, config: AgentConfig, api_key: str): self.config = config self.bs = BlackScholes() self.iv_histories: Dict[str, List] = {} self.market = MarketDataClient(config.purple_flea_api, api_key) self.selector = StrikeSelector(self.bs) self.delta_mgr = DeltaManager( max_portfolio_delta=-0.10, max_single_delta=-0.03, portfolio_value=config.portfolio_value ) self.roller = PositionRoller( profit_target_pct=config.profit_close_pct, loss_threshold_pct=config.loss_close_pct, min_dte_before_roll=config.dte_close_limit ) self.risk_mgr = OptionsRiskManager(config.portfolio_value) self.total_premium_collected = 0.0 self.cycle_count = 0 async def scan_for_entries(self) -> List[dict]: """Scan all assets for valid entry conditions.""" candidates = [] for asset in self.config.scan_assets: try: iv_data = await self.market.get_iv_data(asset) current_iv = iv_data['iv_30d'] iv_rank = iv_data['iv_rank'] iv_percentile = iv_data['iv_percentile'] hv_30d = iv_data['hv_30d'] # Skip if IV conditions not met if iv_rank < self.config.min_iv_rank: logger.info(f'{asset}: IVR {iv_rank:.0f} below threshold, skipping') continue if iv_percentile < self.config.min_iv_percentile: logger.info(f'{asset}: IVP {iv_percentile:.0f} below threshold, skipping') continue S = await self.market.get_price(asset) chain = await self.market.get_option_chain( asset, self.config.target_dte_min, self.config.target_dte_max ) # Adjust delta target based on IV conditions iv_analysis = IVAnalysis( current_iv=current_iv, iv_rank=iv_rank, iv_percentile=iv_percentile, hv_30d=hv_30d, vol_premium=current_iv-hv_30d, entry_signal='STRONG' if iv_rank >= 70 else 'MODERATE', recommended_delta=IVAnalyzer([]).suggest_delta(iv_rank, iv_percentile) ) target_delta = iv_analysis.recommended_delta target_dte = (35 if iv_rank < 60 else 30) T = target_dte / 365 strike, greeks = self.selector.find_target_strike( S, T, 0.05, current_iv, target_delta, [s['strike'] for s in chain] ) if strike is None: continue # Evaluate via strategy builder builder = StrategyBuilder(self.bs) setup = builder.build_csp(asset, S, strike, target_dte, current_iv) eval_result = builder.evaluate_minimum_premium(setup) if eval_result['trade_ok']: # Check risk manager approval size = self.risk_mgr.compute_position_size(strike, current_iv, asset) if size['max_contracts'] > 0: candidates.append({ 'asset': asset, 'strike': strike, 'dte': target_dte, 'premium': greeks.price, 'delta': greeks.delta, 'iv_rank': iv_rank, 'annualized_return': setup.annualized_return_pct, 'max_contracts': size['max_contracts'], 'score': iv_rank * setup.annualized_return_pct # rank by score }) except Exception as e: logger.error(f'Error scanning {asset}: {e}') # Sort by score: highest IV rank * return first candidates.sort(key=lambda x: x['score'], reverse=True) return candidates async def manage_positions(self): """Evaluate all open positions and roll/close as needed.""" positions = self.delta_mgr.positions summary = self.delta_mgr.portfolio_summary() logger.info(f'Managing {summary["open_positions"]} positions, PnL: ${summary["total_unrealized_pnl"]:.2f}') to_close = self.delta_mgr.positions_to_close( profit_target_pct=self.config.profit_close_pct, dte_limit=self.config.dte_close_limit, loss_limit_pct=self.config.loss_close_pct ) for close_item in to_close: logger.info(f'Closing position {close_item["position_id"]}: {close_item["reasons"]}') # Execute close order via API... async def run_cycle(self): """Execute one full agent cycle: manage → scan → enter.""" self.cycle_count += 1 logger.info(f'=== Cycle {self.cycle_count} === {datetime.now().isoformat()}') # Step 1: manage existing positions await self.manage_positions() # Step 2: scan for new entries if capacity available current_positions = len(self.delta_mgr.positions) if current_positions < self.config.max_positions: candidates = await self.scan_for_entries() slots_available = self.config.max_positions - current_positions for candidate in candidates[:slots_available]: logger.info( f'Entry signal: {candidate["asset"]} ${candidate["strike"]:.0f} put, ' f'{candidate["dte"]}DTE, ${candidate["premium"]:.2f} premium, ' f'IVR={candidate["iv_rank"]:.0f}, ann={candidate["annualized_return"]:.1f}%' ) # Execute sell order via API... self.total_premium_collected += candidate['premium'] * candidate['max_contracts'] # Step 3: log portfolio status summary = self.delta_mgr.portfolio_summary() stress = self.risk_mgr.stress_test([-20, -30]) logger.info(f'Portfolio: {summary}') logger.info(f'Stress test: {stress}') logger.info(f'Total premium collected: ${self.total_premium_collected:.2f}') async def run(self): """Main agent loop.""" logger.info(f'OptionsSellingAgent starting. Portfolio: ${self.config.portfolio_value:,.0f}') logger.info(f'Scanning: {self.config.scan_assets}') while True: try: await self.run_cycle() except Exception as e: logger.error(f'Cycle error: {e}', exc_info=True) await asyncio.sleep(self.config.run_interval_minutes * 60) # Entry point if __name__ == '__main__': logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') config = AgentConfig( portfolio_value=50_000, target_monthly_income_pct=2.0, min_iv_rank=50, scan_assets=['BTC', 'ETH'] ) agent = OptionsSellingAgent(config, api_key='YOUR_API_KEY') asyncio.run(agent.run())
Start Collecting Theta Income
Access 275+ derivatives markets, live option chains, and automated execution infrastructure built for autonomous AI agents.