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.
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.
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.
Near-term expirations often carry higher IV than longer-dated ones in calm markets (backwardation), but this reverses during uncertainty events.
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.
| Property | Equity (S&P 500) | Crypto (BTC/ETH) |
|---|---|---|
| ATM Vol Level | 12–25% | 40–120% |
| Skew Direction | Persistent put skew | Can flip to call skew in bull markets |
| Term Structure Shape | Usually contango | Frequently inverted around events |
| Surface Stability | Mean-reverting over days | Can gap 30–50 vol points overnight |
| Expiry Granularity | Weekly + monthly | Daily on some platforms, weekly/quarterly on others |
| Vol Risk Premium | 4–6 vol points on average | 8–15 vol points historically |
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.
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 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:
| Parameter | Interpretation | Typical Crypto Range |
|---|---|---|
a | Overall level of variance | 0.1 – 2.0 |
b | Slope of both wings | 0.2 – 1.5 |
rho | Skewness (correlation): negative = put skew | -0.8 – 0.2 |
m | Shift of minimum from ATM | -0.3 – 0.3 |
sigma | Smile curvature (ATM vol of vol) | 0.1 – 0.8 |
A valid IV surface must satisfy:
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)
| RR25 Value | Market Interpretation | Agent Trading Implication |
|---|---|---|
| Large negative (< -5%) | Strong put skew: fear dominates, crash insurance is expensive | Buy upside calls, sell crash puts (fade the skew) |
| Mildly negative (-5% to -1%) | Normal risk aversion; standard equity-like put premium | Neutral; collect theta via strangles |
| Near zero | Symmetric vol; uncertainty about direction | Long gamma positions ahead of catalysts |
| Positive (> 1%) | Call skew: bull market euphoria, FOMO premium | Sell calls, buy puts (fade the euphoria) |
| Strongly positive (> 5%) | Extreme call demand; potential blow-off top signal | Long put spreads as trend-fade |
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.
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.
Short-dated IV < long-dated IV. Market expects future uncertainty to be higher than near-term. Common in quiet, trending bull markets.
Short-dated IV > long-dated IV. Immediate uncertainty is elevated. Seen before and during crashes, major events, or high-leverage liquidation spirals.
A specific expiry shows elevated IV vs neighbors. Often caused by scheduled events: Fed meetings, earnings-equivalent announcements, ETF deadlines.
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 Signal | Meaning | Trade |
|---|---|---|
| 1m IV > 3m IV by 10+ vol pts | Near-term fear spike; expect reversion | Short front-month vol, long back-month (calendar spread) |
| 3m IV > 1m IV by 5+ vol pts | Calm now, uncertainty later; buy time | Long front-month gamma, short back-month vega |
| All expiries compressed (<40% BTC) | Complacency; vol too cheap | Long gamma across the board; straddles |
| Event hump visible at T+2w | Known catalyst priced into that expiry | Sell the event expiry vol, hedge with neighbors |
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)
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 Type | Structure | Profit Scenario | Max Loss |
|---|---|---|---|
| Calendar (long) | Short near, long far (same strike) | Near-term vol collapses; term structure steepens | Net debit paid |
| Calendar (short) | Long near, short far (same strike) | Near-term vol spikes; term structure inverts | Theoretically large if far-term vol rises |
| Long Butterfly | Buy wing calls, sell 2x ATM calls | Underlying pins at ATM at expiry | Net debit paid |
| Short Butterfly | Sell wing calls, buy 2x ATM calls | Underlying moves far from ATM (volatile) | Spread width minus premium received |
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.
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.
Near-term total variance exceeds far-term total variance at the same strike (calendar arbitrage violation). Buy the far-term, sell the near-term.
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.
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.
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.
The following Python agent connects to Purple Flea's trading API, constructs the IV surface, identifies mispricings, and executes vol arb trades autonomously.
"""
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
))
| Parameter | Default | Description |
|---|---|---|
vrp_threshold | 0.10 | Min VRP (IV - HV) to trigger vol-selling signal (10 vol points) |
skew_threshold | 0.05 | Min RR25 magnitude to trigger skew-fade signal (5 vol points) |
min_confidence | 0.75 | Minimum signal confidence to auto-execute trades |
interval_seconds | 300 | Scan cycle interval in seconds (5 minutes) |
notional | 1000 USDC | Default trade size per signal |
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.
| Greek | What It Measures | Vol Trader Concern | Hedge |
|---|---|---|---|
| Vega | P&L per 1% change in IV | Parallel shift in the entire surface | Offset with opposite-vega position or variance swap |
| Vanna | Change in delta per change in IV | Risk reversal positions as spot moves | Re-hedge delta more frequently near skew peaks |
| Volga | Change in vega per change in IV (vol of vol) | Butterfly positions; wing positions sensitive to smile curvature changes | Long butterflies as vanna/volga hedge |
| Charm | Change in delta per unit time | Near-expiry positions' delta drifts rapidly | Close or roll positions approaching expiry |
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.
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.
Visit /faucet to register your agent wallet and claim $1 USDC with no risk required.
Purple Flea Trading offers 275+ perpetual markets with options data via REST and WebSocket APIs.
Use the VolSurfaceAgent above or build on top of it. The MCP server at /mcp exposes all trading tools natively.
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.