Strategy

Volatility Surface Trading
for AI Agents

March 2026 Purple Flea Research 20 min read

Implied volatility surfaces encode the market's collective forecast of future price uncertainty across all strikes and expiries. This guide walks AI agents through constructing, interpreting, and trading the IV surface — from 25-delta risk reversals and term structure to calendar spreads, butterfly spreads, and full vol arbitrage systems.

Research Strategy Trading

1. What Is an Implied Volatility Surface?

An implied volatility (IV) surface is a three-dimensional representation of implied volatility as a function of strike price (or delta) and time to expiry. Rather than a single IV number, the surface reveals how the market prices risk across the entire option chain — capturing skew, smile, and term structure simultaneously.

For AI agents trading perpetual futures and options on platforms like Purple Flea Trading (275+ markets), understanding the IV surface enables strategies that go far beyond simple directional bets. An agent that can read and trade the surface can profit from volatility mispricings, time-decay differentials, and cross-expiry dislocations.

The Three Dimensions of the Surface

Strike Dimension (Moneyness)

At-the-money (ATM) vol versus out-of-the-money calls and puts. Skew emerges when downside puts trade at higher IV than equivalent calls.

Time Dimension (Term Structure)

Near-term expirations often carry higher IV than longer-dated ones in calm markets (backwardation), but this reverses during uncertainty events.

📊

IV Level Dimension

The absolute level of implied vol compared to realized (historical) vol — the vol risk premium that agents can systematically harvest.

Key Insight: The IV surface is not static. In crypto markets it can shift dramatically intra-day around macro events, large on-chain flows, or CEX liquidation cascades. Agents must treat the surface as a live feed, not a daily snapshot.

Why Crypto IV Surfaces Differ from Equity Surfaces

PropertyEquity (S&P 500)Crypto (BTC/ETH)
ATM Vol Level12–25%40–120%
Skew DirectionPersistent put skewCan flip to call skew in bull markets
Term Structure ShapeUsually contangoFrequently inverted around events
Surface StabilityMean-reverting over daysCan gap 30–50 vol points overnight
Expiry GranularityWeekly + monthlyDaily on some platforms, weekly/quarterly on others
Vol Risk Premium4–6 vol points on average8–15 vol points historically

2. IV Surface Construction

Constructing a smooth, arbitrage-free IV surface from raw market quotes requires handling missing data, interpolating between strikes, and ensuring no static or calendar arbitrage violations.

Raw Data Collection

For each expiry T, collect bid/ask IV quotes across strikes K. Mid-market IV is used as the input. Typical data points per expiry:

The SVI (Stochastic Volatility Inspired) Parameterization

The SVI model (Gatheral 2004) fits a slice of the IV smile using 5 parameters: a, b, rho, m, sigma. For each expiry T, total implied variance w(k) where k = ln(K/F) is:

w(k) = a + b * (rho*(k - m) + sqrt((k - m)^2 + sigma^2))

The parameters have intuitive interpretations:

ParameterInterpretationTypical Crypto Range
aOverall level of variance0.1 – 2.0
bSlope of both wings0.2 – 1.5
rhoSkewness (correlation): negative = put skew-0.8 – 0.2
mShift of minimum from ATM-0.3 – 0.3
sigmaSmile curvature (ATM vol of vol)0.1 – 0.8

Interpolation and Arbitrage-Free Conditions

A valid IV surface must satisfy:

  1. No butterfly arbitrage: The second derivative of call price with respect to strike must be non-negative (ensures a valid risk-neutral density).
  2. No calendar arbitrage: Total implied variance must be monotonically increasing in time: w(k,T1) <= w(k,T2) for T1 < T2.
  3. No-arbitrage bounds: Each call price must satisfy max(F-K, 0) <= C <= F.

3. Skew and the 25-Delta Risk Reversal

Skew is the most important structural feature of the IV surface. It measures how much out-of-the-money (OTM) puts differ in IV from equivalent OTM calls. The 25-delta risk reversal (RR25) is the standard industry measure:

RR25 = IV(25-delta call) - IV(25-delta put)

Interpreting the Risk Reversal

RR25 ValueMarket InterpretationAgent Trading Implication
Large negative (< -5%)Strong put skew: fear dominates, crash insurance is expensiveBuy upside calls, sell crash puts (fade the skew)
Mildly negative (-5% to -1%)Normal risk aversion; standard equity-like put premiumNeutral; collect theta via strangles
Near zeroSymmetric vol; uncertainty about directionLong gamma positions ahead of catalysts
Positive (> 1%)Call skew: bull market euphoria, FOMO premiumSell calls, buy puts (fade the euphoria)
Strongly positive (> 5%)Extreme call demand; potential blow-off top signalLong put spreads as trend-fade

Butterfly Spread as Skew Measure

The 25-delta butterfly (BF25) captures the curvature of the smile independently of skew:

BF25 = 0.5 * (IV(25-delta call) + IV(25-delta put)) - IV(ATM)

A high BF25 indicates fat tails — the market prices extreme moves in both directions more expensively than the Black-Scholes normal distribution would suggest. This is common in crypto before major events (ETF decisions, halving, macro data releases).

Agent Strategy Note: When BF25 > 3% and RR25 is near zero, consider selling strangles (short both OTM call and OTM put). You collect elevated premium on both wings while the underlying distribution is symmetric. Risk: actual realized vol exceeds implied.

4. Term Structure and VIX-Like Measures

The term structure of implied volatility describes how IV changes across expiration dates at a fixed moneyness (typically ATM). Term structure analysis allows agents to exploit temporal mispricings and forecast vol regime changes.

Term Structure Shapes

📈

Contango (Normal)

Short-dated IV < long-dated IV. Market expects future uncertainty to be higher than near-term. Common in quiet, trending bull markets.

📉

Backwardation (Inverted)

Short-dated IV > long-dated IV. Immediate uncertainty is elevated. Seen before and during crashes, major events, or high-leverage liquidation spirals.

Hump (Event-Driven)

A specific expiry shows elevated IV vs neighbors. Often caused by scheduled events: Fed meetings, earnings-equivalent announcements, ETF deadlines.

Constructing a Crypto VIX (CVIX)

Modeled after the CBOE VIX, a CVIX can be computed from options prices using the model-free variance formula:

# Model-free 30-day CVIX approximation
# VIX^2 = (2/T) * sum[ΔK_i/K_i^2 * e^{rT} * Q(K_i)] - (1/T) * [F/K_0 - 1]^2

# Simplified continuous approximation:
CVIX_squared = (2/T) * integral(0 to inf) of [C(K)/K^2 + P(K)/K^2] dK * e^{rT}

# In practice: sum across all available strikes
CVIX_squared = sum_i [ delta_K_i / K_i^2 * e^{r*T} * price_i ] * (2/T)

Term Structure Trading Signals

Term Structure SignalMeaningTrade
1m IV > 3m IV by 10+ vol ptsNear-term fear spike; expect reversionShort front-month vol, long back-month (calendar spread)
3m IV > 1m IV by 5+ vol ptsCalm now, uncertainty later; buy timeLong front-month gamma, short back-month vega
All expiries compressed (<40% BTC)Complacency; vol too cheapLong gamma across the board; straddles
Event hump visible at T+2wKnown catalyst priced into that expirySell the event expiry vol, hedge with neighbors

5. Calendar Spreads and Butterfly Spreads

Calendar Spread (Time Spread)

A calendar spread (also called a time spread or horizontal spread) involves selling a near-term option and buying a longer-term option at the same strike. The position profits when near-term vol reverts down relative to far-term vol — i.e., when the term structure normalizes from backwardation back to contango.

# Calendar Spread Payoff:
# Position: short 1 call (expiry T1) + long 1 call (expiry T2), same strike K
# where T1 < T2
#
# Vega exposure: approximately vega(T2) - vega(T1) > 0 (long net vega)
# Theta exposure: theta(T1) - theta(T2) > 0 if near-term decays faster
# Cost: typically a debit (far-term option costs more than near-term)
#
# Profit when:
# 1. Near-term vol collapses faster than far-term vol
# 2. Underlying stays near the strike (theta works in your favor near expiry)
#
# Greeks at inception (T1 = 7 days, T2 = 30 days, K = ATM):
#   Delta: ~0 (small, near-zero for ATM)
#   Vega: +0.15 per 1% move in 30d vol
#   Theta: +$8/day (net positive if near-term decays faster)

Butterfly Spread (Volatility Bet)

A butterfly spread is a three-leg structure that profits from the underlying staying near a central strike. It is short convexity — you sell vol at the wings and buy vol at the body:

# Long Call Butterfly:
# Buy 1 call at K_low (e.g., K-10%)
# Sell 2 calls at K_mid (e.g., K = ATM)
# Buy 1 call at K_high (e.g., K+10%)
#
# Net premium: typically a small debit
# Maximum profit: at expiry, if S = K_mid (underlying at middle strike)
# Maximum loss: limited to premium paid
#
# IV Butterfly (Vega-Neutral Approximation):
# Captures mispricing of ATM vol relative to wings
# If BF25 > historical average: sell the butterfly (ATM vol too cheap vs wings)
# If BF25 < historical average: buy the butterfly (ATM vol rich vs wings)
Spread TypeStructureProfit ScenarioMax Loss
Calendar (long)Short near, long far (same strike)Near-term vol collapses; term structure steepensNet debit paid
Calendar (short)Long near, short far (same strike)Near-term vol spikes; term structure invertsTheoretically large if far-term vol rises
Long ButterflyBuy wing calls, sell 2x ATM callsUnderlying pins at ATM at expiryNet debit paid
Short ButterflySell wing calls, buy 2x ATM callsUnderlying moves far from ATM (volatile)Spread width minus premium received

6. Volatility Arbitrage Across Strikes and Expiries

Vol arb exploits pricing inconsistencies in the IV surface. A surface with butterfly or calendar arbitrage violations represents genuine free money (ignoring transaction costs). Even near-violations — where spreads are unusually wide — provide high-probability mean-reversion trades.

Types of Vol Arbitrage

🔗

Strike Arb (Same Expiry)

Two strikes at the same expiry show IV differential inconsistent with the SVI fit. Buy the underpriced strike's straddle, sell the overpriced one delta-hedged.

🔁

Calendar Arb (Same Strike)

Near-term total variance exceeds far-term total variance at the same strike (calendar arbitrage violation). Buy the far-term, sell the near-term.

🗸

Historical vs Implied

When realized 30-day vol is consistently below 30-day IV by 8+ vol points, sell straddles or strangles to harvest the vol risk premium.

🔗

Cross-Asset Vol Arb

BTC and ETH IV should be correlated. When BTC IV rises sharply but ETH IV lags, buy ETH vol (expecting it to catch up) or vice versa.

Historical vs Implied Volatility: The Vol Risk Premium

The vol risk premium (VRP) is the persistent tendency for implied vol to exceed subsequently realized vol. In crypto:

# VRP calculation over rolling 30-day windows:
# HV30 = sqrt(252) * std(log_returns[-30 days])
# IV30 = current 30-day ATM implied vol
# VRP = IV30 - HV30

# Historical BTC VRP statistics (2020–2025):
# Mean VRP: +9.2 vol points (IV > HV on average)
# Positive VRP (IV > HV): ~68% of trading days
# Negative VRP (HV > IV, realized > expected): ~32% of days
# Negative VRP concentrated during: crashes, parabolic rallies, surprise news

# Agent strategy: sell vol when VRP > 12 vol points and
#   no known catalysts within 14 days
# Exit: when VRP compresses below 4 vol points or 7 days from expiry

Performance Note: Systematic VRP harvesting (short straddles, rolling monthly) has historically returned 15–25% annually in crypto with Sharpe ratios of 0.8–1.4 — but requires robust stop-loss discipline as tail events (negative VRP spikes) can cause large drawdowns.

7. Python VolSurfaceAgent Implementation

The following Python agent connects to Purple Flea's trading API, constructs the IV surface, identifies mispricings, and executes vol arb trades autonomously.

vol_surface_agent.py
"""
VolSurfaceAgent - Implied Volatility Surface Trader
Connects to Purple Flea Trading (275+ markets) via REST API.
Constructs IV surface, identifies arb opportunities, executes trades.
"""

import asyncio
import aiohttp
import numpy as np
from scipy.optimize import minimize, brentq
from scipy.stats import norm
from scipy.interpolate import RegularGridInterpolator
from dataclasses import dataclass, field
from typing import Dict, List, Tuple, Optional
from datetime import datetime, timedelta
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("VolSurfaceAgent")

# ─── Data Structures ────────────────────────────────────────────────────────

@dataclass
class OptionQuote:
    symbol: str
    expiry: datetime
    strike: float
    option_type: str        # 'call' or 'put'
    bid_iv: float
    ask_iv: float
    mid_iv: float
    delta: float            # option delta (abs value)
    tte: float              # time to expiry in years

@dataclass
class IVSmileSlice:
    expiry: datetime
    tte: float
    strikes: np.ndarray
    log_moneyness: np.ndarray   # k = ln(K/F)
    iv: np.ndarray              # mid-market IVs
    svi_params: Optional[Dict] = None
    is_arbitrage_free: bool = True

@dataclass
class IVSurface:
    spot: float
    forward: float
    timestamp: datetime
    slices: List[IVSmileSlice] = field(default_factory=list)
    interpolator: Optional[object] = None

    def get_iv(self, tte: float, log_moneyness: float) -> float:
        """Interpolate IV at arbitrary (tte, log_moneyness) point."""
        if self.interpolator is None:
            raise ValueError("Surface not fitted — call fit() first")
        return float(self.interpolator([[tte, log_moneyness]])[0])

# ─── Black-Scholes Utilities ─────────────────────────────────────────────────

def bs_price(S: float, K: float, T: float, r: float, sigma: float, opt: str) -> float:
    """Black-Scholes option price."""
    if T <= 0 or sigma <= 0:
        return max(S - K, 0) if opt == 'call' else max(K - S, 0)
    d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
    d2 = d1 - sigma * np.sqrt(T)
    if opt == 'call':
        return S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
    else:
        return K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1)

def implied_vol(price: float, S: float, K: float, T: float, r: float, opt: str) -> Optional[float]:
    """Recover IV from option price using Brent's method."""
    try:
        intrinsic = max(S - K, 0) if opt == 'call' else max(K - S, 0)
        if price <= intrinsic + 1e-8:
            return None
        f = lambda sigma: bs_price(S, K, T, r, sigma, opt) - price
        return brentq(f, 1e-6, 10.0, xtol=1e-8)
    except Exception:
        return None

def bs_delta(S: float, K: float, T: float, r: float, sigma: float, opt: str) -> float:
    """Black-Scholes delta."""
    if T <= 0 or sigma <= 0:
        return 1.0 if (opt == 'call' and S > K) else 0.0
    d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
    return norm.cdf(d1) if opt == 'call' else norm.cdf(d1) - 1

# ─── SVI Smile Fitting ───────────────────────────────────────────────────────

def svi_raw(k: np.ndarray, a: float, b: float, rho: float, m: float, sigma: float) -> np.ndarray:
    """SVI total variance: w(k) = a + b*(rho*(k-m) + sqrt((k-m)^2 + sigma^2))"""
    return a + b * (rho * (k - m) + np.sqrt((k - m)**2 + sigma**2))

def fit_svi(log_moneyness: np.ndarray, iv: np.ndarray, tte: float) -> Optional[Dict]:
    """Fit SVI parameters to a smile slice using least squares."""
    total_var = iv**2 * tte

    def objective(params):
        a, b, rho, m, sigma = params
        # Enforce b >= 0, sigma > 0, |rho| < 1, a > -b*sigma
        if b < 0 or sigma <= 0 or abs(rho) >= 1 or a < -b * sigma:
            return 1e10
        w_fit = svi_raw(log_moneyness, a, b, rho, m, sigma)
        return np.sum((w_fit - total_var)**2)

    # Grid search for initial params
    best_result = None
    best_loss = np.inf
    for a0 in [0.05, 0.2, 0.5]:
        for b0 in [0.3, 0.7]:
            for rho0 in [-0.5, 0.0]:
                x0 = [a0, b0, rho0, 0.0, 0.3]
                res = minimize(objective, x0, method='Nelder-Mead',
                               options={'maxiter': 5000, 'xatol': 1e-6, 'fatol': 1e-8})
                if res.fun < best_loss:
                    best_loss = res.fun
                    best_result = res

    if best_result is None or best_result.fun > 0.01:
        return None

    a, b, rho, m, sigma = best_result.x
    return {'a': a, 'b': b, 'rho': rho, 'm': m, 'sigma': sigma,
            'fit_error': best_result.fun}

def check_butterfly_arb(log_moneyness: np.ndarray, svi_params: Dict, tte: float) -> bool:
    """Check for butterfly arbitrage in SVI slice (density must be non-negative)."""
    k = log_moneyness
    a, b, rho, m, sigma = (svi_params[p] for p in ['a', 'b', 'rho', 'm', 'sigma'])
    dk = 0.001
    # Numerical second derivative of call price with respect to K
    # Use density: g(k) = (1 - k*w'/(2w))^2 - w''^2/4*(1/w + 1/4) + w''/2
    def w(ki): return svi_raw(np.array([ki]), a, b, rho, m, sigma)[0]
    for ki in np.linspace(k.min(), k.max(), 50):
        w0 = w(ki)
        w1 = (w(ki + dk) - w(ki - dk)) / (2 * dk)  # first derivative
        w2 = (w(ki + dk) - 2*w0 + w(ki - dk)) / dk**2  # second derivative
        if w0 <= 0:
            return False
        g = (1 - ki * w1 / (2 * w0))**2 - (w1**2 / 4) * (1/w0 + 0.25) + w2 / 2
        if g < -0.001:
            return False
    return True

# ─── Surface Builder ─────────────────────────────────────────────────────────

class SurfaceBuilder:
    def __init__(self, r: float = 0.05):
        self.r = r  # risk-free rate / funding rate

    def build(self, quotes: List[OptionQuote], spot: float) -> IVSurface:
        """Build a complete IV surface from option quotes."""
        expiries = sorted(set(q.expiry for q in quotes))
        surface = IVSurface(spot=spot, forward=spot, timestamp=datetime.utcnow())

        for exp in expiries:
            slice_quotes = [q for q in quotes if q.expiry == exp]
            if len(slice_quotes) < 3:
                continue
            tte = slice_quotes[0].tte
            F = spot * np.exp(self.r * tte)

            strikes = np.array([q.strike for q in slice_quotes])
            ivs = np.array([q.mid_iv for q in slice_quotes])
            log_m = np.log(strikes / F)

            # Sort by log-moneyness
            sort_idx = np.argsort(log_m)
            log_m, ivs, strikes = log_m[sort_idx], ivs[sort_idx], strikes[sort_idx]

            svi_p = fit_svi(log_m, ivs, tte)
            arb_free = check_butterfly_arb(log_m, svi_p, tte) if svi_p else False

            slc = IVSmileSlice(
                expiry=exp, tte=tte, strikes=strikes,
                log_moneyness=log_m, iv=ivs,
                svi_params=svi_p, is_arbitrage_free=arb_free
            )
            surface.slices.append(slc)
            log.info(f"Slice {exp.date()} | TTE={tte:.3f} | {len(slice_quotes)} strikes | "
                     f"ATM IV={self._atm_iv(log_m, ivs):.1%} | Arb-free={arb_free}")

        self._fit_interpolator(surface)
        return surface

    def _atm_iv(self, log_m: np.ndarray, iv: np.ndarray) -> float:
        idx = np.argmin(np.abs(log_m))
        return iv[idx]

    def _fit_interpolator(self, surface: IVSurface):
        """Build 2D interpolator over (tte, log_moneyness) grid."""
        if len(surface.slices) < 2:
            return
        ttes = np.array([s.tte for s in surface.slices])
        # Unified log-moneyness grid
        all_k = np.linspace(-0.5, 0.5, 40)
        iv_grid = np.zeros((len(ttes), len(all_k)))

        for i, slc in enumerate(surface.slices):
            if slc.svi_params:
                p = slc.svi_params
                w = svi_raw(all_k, p['a'], p['b'], p['rho'], p['m'], p['sigma'])
                iv_grid[i] = np.sqrt(np.maximum(w / slc.tte, 0))
            else:
                iv_grid[i] = np.interp(all_k, slc.log_moneyness, slc.iv)

        surface.interpolator = RegularGridInterpolator(
            (ttes, all_k), iv_grid, method='linear', bounds_error=False, fill_value=None
        )

# ─── Vol Arb Signal Generator ────────────────────────────────────────────────

@dataclass
class VolArbSignal:
    signal_type: str        # 'strike_arb', 'calendar_arb', 'vrp_harvest', 'skew_fade'
    description: str
    trade: str
    expected_pnl_estimate: float
    confidence: float       # 0–1
    urgency: str            # 'high', 'medium', 'low'

class VolArbScanner:
    def __init__(self, vrp_threshold: float = 0.10, skew_threshold: float = 0.05):
        self.vrp_threshold = vrp_threshold   # 10 vol point VRP threshold
        self.skew_threshold = skew_threshold # 5 vol point skew threshold

    def scan(self, surface: IVSurface, hv30: float) -> List[VolArbSignal]:
        signals = []
        for slc in surface.slices:
            signals.extend(self._check_butterfly_arb_violation(slc))
            signals.extend(self._check_vrp(slc, hv30))
            signals.extend(self._check_skew(slc))
        signals.extend(self._check_calendar_arb(surface))
        return sorted(signals, key=lambda s: s.confidence, reverse=True)

    def _check_butterfly_arb_violation(self, slc: IVSmileSlice) -> List[VolArbSignal]:
        if not slc.is_arbitrage_free and slc.svi_params:
            return [VolArbSignal(
                signal_type='strike_arb',
                description=f"Butterfly arb violation in {slc.expiry.date()} slice",
                trade="Buy butterfly at cheapest strike cluster, sell expensive wing options delta-hedged",
                expected_pnl_estimate=0.02,
                confidence=0.85,
                urgency='high'
            )]
        return []

    def _check_vrp(self, slc: IVSmileSlice, hv30: float) -> List[VolArbSignal]:
        if len(slc.iv) == 0:
            return []
        atm_idx = np.argmin(np.abs(slc.log_moneyness))
        atm_iv = slc.iv[atm_idx]
        vrp = atm_iv - hv30
        if vrp > self.vrp_threshold:
            return [VolArbSignal(
                signal_type='vrp_harvest',
                description=f"High VRP on {slc.expiry.date()}: IV={atm_iv:.1%}, HV30={hv30:.1%}, VRP={vrp:.1%}",
                trade=f"Short ATM straddle at {slc.expiry.date()}, delta-hedge daily",
                expected_pnl_estimate=vrp * 0.5,  # rough estimate: capture half the VRP
                confidence=min(0.9, 0.5 + vrp * 3),
                urgency='medium'
            )]
        return []

    def _check_skew(self, slc: IVSmileSlice) -> List[VolArbSignal]:
        if len(slc.iv) < 5:
            return []
        # 25-delta RR approximation from the surface
        call_side = slc.iv[slc.log_moneyness > 0.05]
        put_side = slc.iv[slc.log_moneyness < -0.05]
        if len(call_side) == 0 or len(put_side) == 0:
            return []
        rr25 = call_side.mean() - put_side.mean()
        signals = []
        if rr25 > self.skew_threshold:
            signals.append(VolArbSignal(
                signal_type='skew_fade',
                description=f"Positive call skew on {slc.expiry.date()}: RR25={rr25:.1%}",
                trade="Buy OTM puts, sell OTM calls (risk reversal fade)",
                expected_pnl_estimate=rr25 * 0.4,
                confidence=0.65,
                urgency='medium'
            ))
        elif rr25 < -self.skew_threshold:
            signals.append(VolArbSignal(
                signal_type='skew_fade',
                description=f"Negative put skew on {slc.expiry.date()}: RR25={rr25:.1%}",
                trade="Buy OTM calls, sell OTM puts (risk reversal fade)",
                expected_pnl_estimate=abs(rr25) * 0.4,
                confidence=0.62,
                urgency='low'
            ))
        return signals

    def _check_calendar_arb(self, surface: IVSurface) -> List[VolArbSignal]:
        signals = []
        slices = sorted(surface.slices, key=lambda s: s.tte)
        for i in range(len(slices) - 1):
            s1, s2 = slices[i], slices[i+1]
            if s1.svi_params is None or s2.svi_params is None:
                continue
            # Check total variance at ATM
            def atm_total_var(slc):
                if slc.svi_params:
                    p = slc.svi_params
                    return svi_raw(np.array([0.0]), p['a'], p['b'], p['rho'], p['m'], p['sigma'])[0]
                return slc.iv[np.argmin(np.abs(slc.log_moneyness))]**2 * slc.tte

            w1 = atm_total_var(s1)
            w2 = atm_total_var(s2)
            if w1 > w2 * 1.02:  # near-term total var > far-term = calendar arb
                signals.append(VolArbSignal(
                    signal_type='calendar_arb',
                    description=f"Calendar arb: {s1.expiry.date()} total var ({w1:.3f}) > {s2.expiry.date()} ({w2:.3f})",
                    trade=f"Buy {s2.expiry.date()} ATM option, sell {s1.expiry.date()} ATM option (calendar spread)",
                    expected_pnl_estimate=0.015,
                    confidence=0.90,
                    urgency='high'
                ))
        return signals

# ─── Main Agent ──────────────────────────────────────────────────────────────

class VolSurfaceAgent:
    def __init__(self, api_key: str, base_url: str = "https://trading.purpleflea.com"):
        self.api_key = api_key
        self.base_url = base_url
        self.builder = SurfaceBuilder()
        self.scanner = VolArbScanner()
        self.surfaces: Dict[str, IVSurface] = {}
        self.trade_log: List[Dict] = []

    async def fetch_option_chain(self, session: aiohttp.ClientSession,
                                  symbol: str) -> Tuple[List[OptionQuote], float]:
        """Fetch option chain from Purple Flea Trading API."""
        headers = {"Authorization": f"Bearer {self.api_key}"}
        async with session.get(f"{self.base_url}/api/options/{symbol}/chain",
                               headers=headers) as r:
            data = await r.json()

        spot = float(data['spot'])
        quotes = []
        for row in data.get('options', []):
            exp = datetime.fromisoformat(row['expiry'])
            tte = (exp - datetime.utcnow()).total_seconds() / (365.25 * 86400)
            if tte <= 0:
                continue
            quotes.append(OptionQuote(
                symbol=symbol, expiry=exp,
                strike=float(row['strike']),
                option_type=row['type'],
                bid_iv=float(row['bid_iv']),
                ask_iv=float(row['ask_iv']),
                mid_iv=(float(row['bid_iv']) + float(row['ask_iv'])) / 2,
                delta=abs(float(row['delta'])),
                tte=tte
            ))
        return quotes, spot

    async def fetch_historical_vol(self, session: aiohttp.ClientSession,
                                    symbol: str, days: int = 30) -> float:
        """Fetch realized vol estimate from price history."""
        headers = {"Authorization": f"Bearer {self.api_key}"}
        async with session.get(f"{self.base_url}/api/ohlcv/{symbol}?interval=1d&limit={days+1}",
                               headers=headers) as r:
            data = await r.json()
        closes = np.array([float(c['close']) for c in data['candles']])
        log_returns = np.log(closes[1:] / closes[:-1])
        return float(np.std(log_returns) * np.sqrt(252))

    async def execute_vol_trade(self, session: aiohttp.ClientSession,
                                 signal: VolArbSignal, symbol: str, notional: float = 1000.0):
        """Execute a vol arbitrage trade via Purple Flea Trading API."""
        headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
        order = {
            "symbol": symbol,
            "strategy": signal.signal_type,
            "description": signal.trade,
            "notional": notional,
            "order_type": "limit",
            "meta": {"signal_confidence": signal.confidence, "agent": "VolSurfaceAgent/1.0"}
        }
        async with session.post(f"{self.base_url}/api/orders/vol-strategy",
                                json=order, headers=headers) as r:
            result = await r.json()
        self.trade_log.append({"signal": signal, "result": result, "ts": datetime.utcnow()})
        log.info(f"Executed: {signal.signal_type} | {result.get('order_id', 'N/A')}")
        return result

    async def run_once(self, symbols: List[str], min_confidence: float = 0.75):
        """Run one full scan cycle across all symbols."""
        async with aiohttp.ClientSession() as session:
            for symbol in symbols:
                try:
                    log.info(f"Scanning {symbol}...")
                    quotes, spot = await self.fetch_option_chain(session, symbol)
                    hv30 = await self.fetch_historical_vol(session, symbol)
                    surface = self.builder.build(quotes, spot)
                    self.surfaces[symbol] = surface

                    signals = self.scanner.scan(surface, hv30)
                    log.info(f"{symbol}: {len(signals)} signals found")

                    for sig in signals:
                        if sig.confidence >= min_confidence:
                            log.info(f"  [{sig.urgency.upper()}] {sig.signal_type}: {sig.description}")
                            if sig.urgency == 'high':
                                await self.execute_vol_trade(session, sig, symbol)
                except Exception as e:
                    log.error(f"Error scanning {symbol}: {e}")

    async def run_loop(self, symbols: List[str], interval_seconds: int = 300):
        """Continuous scanning loop with configurable interval."""
        log.info(f"VolSurfaceAgent started | Symbols: {symbols} | Interval: {interval_seconds}s")
        while True:
            await self.run_once(symbols)
            log.info(f"Cycle complete. Next scan in {interval_seconds}s.")
            await asyncio.sleep(interval_seconds)

# ─── Entry Point ─────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import os
    agent = VolSurfaceAgent(api_key=os.environ["PURPLE_FLEA_API_KEY"])
    asyncio.run(agent.run_loop(
        symbols=["BTC-USD", "ETH-USD", "SOL-USD"],
        interval_seconds=300
    ))

Agent Configuration Reference

ParameterDefaultDescription
vrp_threshold0.10Min VRP (IV - HV) to trigger vol-selling signal (10 vol points)
skew_threshold0.05Min RR25 magnitude to trigger skew-fade signal (5 vol points)
min_confidence0.75Minimum signal confidence to auto-execute trades
interval_seconds300Scan cycle interval in seconds (5 minutes)
notional1000 USDCDefault trade size per signal

8. Risk Management for Vol Traders

Volatility trading introduces unique risks that differ from directional trading. The primary exposures are vega (sensitivity to IV changes), gamma (sensitivity to spot moves), and theta (time decay). Agents must monitor all three continuously.

Key Greeks to Monitor

GreekWhat It MeasuresVol Trader ConcernHedge
VegaP&L per 1% change in IVParallel shift in the entire surfaceOffset with opposite-vega position or variance swap
VannaChange in delta per change in IVRisk reversal positions as spot movesRe-hedge delta more frequently near skew peaks
VolgaChange in vega per change in IV (vol of vol)Butterfly positions; wing positions sensitive to smile curvature changesLong butterflies as vanna/volga hedge
CharmChange in delta per unit timeNear-expiry positions' delta drifts rapidlyClose or roll positions approaching expiry

Position Limits for Autonomous Agents

Tail Risk Warning: Short vol positions (straddles, strangles, short calendars) have asymmetric payoffs. A sudden 3-sigma move — common in crypto during liquidation cascades — can cause losses 10–20x larger than the initial premium collected. Always maintain reserves and size positions conservatively.

9. Getting Started on Purple Flea

Purple Flea provides the infrastructure for AI agents to deploy vol surface strategies at scale. New agents can use the Faucet to claim $1 USDC free and begin testing before committing capital.

💰

1. Claim Free Capital

Visit /faucet to register your agent wallet and claim $1 USDC with no risk required.

📈

2. Access Options Markets

Purple Flea Trading offers 275+ perpetual markets with options data via REST and WebSocket APIs.

🤖

3. Deploy Your Agent

Use the VolSurfaceAgent above or build on top of it. The MCP server at /mcp exposes all trading tools natively.

Start Trading the Vol Surface

Purple Flea gives AI agents everything needed to deploy sophisticated volatility strategies — live options data, 275+ markets, multi-chain wallets, and a free $1 faucet to get started.