# --- # jupyter: # jupytext: # cell_metadata_filter: tags,-all # text_representation: # extension: .py # format_name: percent # format_version: '1.3' # jupytext_version: 1.19.3 # kernelspec: # display_name: Python 3 (ipykernel) # language: python # name: python3 # --- # %% [markdown] # # S&P 500 Options Analytics # # **Docker image**: `ml4t` # # ## Purpose # # Profile the AlgoSeek S&P 500 options analytics dataset for an 8-symbol 2020 EDA # slice — chain structure, implied-volatility surfaces, data quality, and an early # look at the predictive content that motivates the options case studies. # # ## Learning Objectives # # - Read an option chain and locate strikes, expirations, and call/put pairs. # - Construct and visualize an implied-volatility smile, term structure, and surface. # - Apply IV-convergence and Greeks-validity filters to clean options data. # - Quantify a baseline IV-change → forward-return relationship in the cross section. # # ## Book Reference # # Chapter 2 §2.2 (asset-class market data landscape — derivatives). # # ## Prerequisites # # - Familiarity with daily OHLC equity data (`01_us_equities_eda`). # - The AlgoSeek S&P 500 options EDA parquet at `$ML4T_DATA_PATH/sp500_options/` # (8 representative underlyings: AAPL, AMZN, BA, GOOGL, JPM, KO, MSFT, XOM). # - The S&P 500 daily-bar parquet covering the same 2020 window. # # Loaders used: # # | Dataset | Loader | Coverage | # |---------|--------|----------| # | S&P 500 options (EDA slice) | `load_sp500_options_eda()` | 2020, 8 underlyings | # | S&P 500 daily prices | `load_sp500_daily_bars()` | 2020, same 8 underlyings | # %% """S&P 500 Options Analytics — options chain structure, volatility surfaces, and data quality.""" import plotly.express as px import plotly.graph_objects as go import polars as pl from plotly.subplots import make_subplots from data import load_sp500_daily_bars, load_sp500_options_eda # %% tags=["parameters"] DAILY_START_DATE = "2020-01-01" # %% [markdown] # ## 1. Options Primer for ML Practitioners # # Before diving into the data, let's establish the key concepts that make options # data different from—and complementary to—spot market data. # # ### 1.1 What is an Option? # # An option is a **derivative contract** that gives the holder the right (but not # obligation) to buy or sell an underlying asset at a specified price (strike) # by a specified date (expiration). # # | Type | Right | Profitable When | # |------|-------|-----------------| # | **Call** | Buy at strike | Underlying rises above strike | # | **Put** | Sell at strike | Underlying falls below strike | # # ### 1.2 Why Options Data Matters for ML # # Options prices embed **forward-looking information** that spot prices don't: # # 1. **Implied Volatility (IV)** - Market's expectation of future volatility # 2. **IV Skew** - Relative demand for downside vs upside protection # 3. **Term Structure** - How expectations change across time horizons # 4. **Greeks** - Sensitivities that quantify risk exposures # # This information can predict: # - Future realized volatility # - Underlying price movements (via order flow/positioning) # - Tail risk events (via skew) # # ### 1.3 Moneyness: ITM, ATM, OTM # # Moneyness describes how an option's strike relates to the current spot price: # # | Moneyness | Call (Strike vs Spot) | Put (Strike vs Spot) | Characteristics | # |-----------|----------------------|---------------------|-----------------| # | **ITM** (In-the-money) | Strike < Spot | Strike > Spot | Has intrinsic value | # | **ATM** (At-the-money) | Strike ≈ Spot | Strike ≈ Spot | Highest time value | # | **OTM** (Out-of-the-money) | Strike > Spot | Strike < Spot | Pure time value | # # We typically express moneyness as: **Strike / Spot** (or its log) # - Moneyness = 1.0 → ATM # - Moneyness < 1.0 → ITM call / OTM put # - Moneyness > 1.0 → OTM call / ITM put # # ### 1.4 Option Value Components # # An option's price decomposes into intrinsic value (immediate exercise payoff) and # time value (the remainder, reflecting optionality): # # $$\text{Price} = \text{Intrinsic} + \text{Time}$$ # # $$\text{Intrinsic}_{\text{call}} = \max(0,\; S - K), \qquad \text{Intrinsic}_{\text{put}} = \max(0,\; K - S)$$ # # $$\text{Time} = \text{Price} - \text{Intrinsic}$$ # # Time value reflects: # - Time remaining until expiration # - Expected volatility (IV) # - Interest rates and dividends # %% [markdown] # ## 2. Dataset Overview # # ### 2.1 Data Schema # # | Field | Type | Description | # |-------|------|-------------| # | **Identifiers** | | | # | `timestamp` | Date | Trading date (observation date, EOD snapshot) | # | `symbol` | String | Underlying ticker (e.g., "AAPL", "MSFT") | # | `expiration` | Date | Option expiration date | # | `strike` | Float64 | Strike price in USD | # | `call_put` | String | "C" for call, "P" for put | # | **Prices** | | | # | `bid` | Float64 | Best bid price at close | # | `ask` | Float64 | Best ask price at close | # | `mid_price` | Float64 | Mid-market price: (bid + ask) / 2 | # | `underlying_price` | Float64 | Underlying stock close price | # | **Time** | | | # | `days_to_maturity` | Int32 | Calendar days until expiration | # | **Greeks** | | | # | `delta` | Float64 | ∂V/∂S - Price sensitivity to underlying | # | `gamma` | Float64 | ∂²V/∂S² - Delta sensitivity to underlying | # | `theta` | Float64 | ∂V/∂t - Time decay ($/day, typically negative) | # | `vega` | Float64 | ∂V/∂σ - Sensitivity to volatility | # | `rho` | Float64 | ∂V/∂r - Sensitivity to interest rates | # | **Volatility** | | | # | `implied_vol` | Float64 | Black-Scholes implied volatility | # | `iv_convergence` | String | IV solver status (quality indicator) | # # ### 2.2 IV Convergence Codes # # The `iv_convergence` field indicates IV computation quality: # # | Code | Meaning | Use in Analysis | # |------|---------|-----------------| # | `Converged` | IV solver converged normally | [OK] Highest quality | # | `SmallBid_FlatExtrapol` | Small bid, IV extrapolated | WARNING: Use with caution | # | `IntrVal_FlatExtrapol` | Deep ITM, IV extrapolated | WARNING: Use with caution | # | `IntrVal_PutCallPair` | IV from put-call parity | [OK] Usually reliable | # | `Failed` | IV solver did not converge | [FAIL] Exclude from analysis | # # **Best practice**: Filter for `iv_convergence == "Converged"` for clean analysis. # %% options = load_sp500_options_eda( start_date="2020-01-01", end_date="2020-12-31", include_greeks=True, ) print("=== S&P 500 Options Dataset ===") print(f"Total rows: {len(options):,}") print(f"Columns: {len(options.columns)}") print(f"Date range: {options['timestamp'].min()} to {options['timestamp'].max()}") print(f"Underlyings: {sorted(options['symbol'].unique().to_list())}") # %% daily = load_sp500_daily_bars( symbols=sorted(options["symbol"].unique().to_list()), start_date=DAILY_START_DATE, end_date="2020-12-31", ) print("\n=== S&P 500 Daily Prices ===") print(f"Total rows: {len(daily):,}") print(f"Symbols: {daily['symbol'].n_unique()}") print(f"Date range: {daily['timestamp'].min()} to {daily['timestamp'].max()}") # %% # Quick schema preview print("\n=== Options Schema ===") options.head(3) # %% [markdown] # ## 3. Option Chain Structure # # An **option chain** is the full set of options available for one underlying on one day. # It spans multiple dimensions: # - **Strikes**: Many price levels around the current spot # - **Expirations**: Multiple dates from days to years out # - **Types**: Calls and puts at each strike/expiration # # This creates a 3D grid: `(strike × expiration × call_put)` # %% # Options per symbol per day - how dense are the chains? options_per_symbol = options.group_by(["timestamp", "symbol"]).agg( [ pl.len().alias("n_options"), (pl.col("call_put") == "C").sum().alias("n_calls"), (pl.col("call_put") == "P").sum().alias("n_puts"), pl.col("strike").n_unique().alias("n_strikes"), pl.col("expiration").n_unique().alias("n_expirations"), ] ) print("=== Option Chain Density (per symbol per day) ===") options_per_symbol.select(["n_options", "n_strikes", "n_expirations"]).describe() # %% # Visualize: Distribution of chain sizes fig = px.histogram( options_per_symbol.to_pandas(), x="n_options", nbins=50, title="Option Chain Size Distribution", labels={"n_options": "Number of Options per Symbol/Day", "count": "Frequency"}, ) median_options = float(options_per_symbol["n_options"].median()) fig.add_vline(x=median_options, line_dash="dash", line_color="red") fig.add_annotation(x=median_options, y=0.95, yref="paper", text="Median", showarrow=False) fig.update_layout(showlegend=False) fig.show() # %% [markdown] # ### 3.1 Single Symbol Deep Dive: AAPL # # Let's examine one complete option chain to understand the structure. # %% # AAPL on a specific date sample_date = options["timestamp"].max() aapl_day = options.filter((pl.col("symbol") == "AAPL") & (pl.col("timestamp") == sample_date)) spot = aapl_day["underlying_price"][0] print(f"=== AAPL Option Chain ({sample_date}) ===") print(f"Underlying price: ${spot:.2f}") print(f"Total options: {len(aapl_day):,}") print(f" Calls: {aapl_day.filter(pl.col('call_put') == 'C').height:,}") print(f" Puts: {aapl_day.filter(pl.col('call_put') == 'P').height:,}") print(f"Expirations: {aapl_day['expiration'].n_unique()}") print(f"Strikes: {aapl_day['strike'].n_unique()}") print(f"Strike range: ${aapl_day['strike'].min():.2f} - ${aapl_day['strike'].max():.2f}") # %% # Expiration breakdown exp_breakdown = ( aapl_day.group_by("expiration") .agg([pl.len().alias("n_options"), pl.col("strike").n_unique().alias("n_strikes")]) .sort("expiration") ) print("\n=== AAPL Expirations ===") exp_breakdown.head(10) # %% [markdown] # ### 3.2 Option Chain Heatmap # # Visualize the entire chain as a heatmap: strikes on y-axis, expirations on x-axis, # colored by implied volatility. This reveals the **volatility surface** structure. # %% # Prepare data for heatmap - calls only, converged IV, reasonable moneyness aapl_calls = ( aapl_day.filter( (pl.col("call_put") == "C") & (pl.col("iv_convergence") == "Converged") & (pl.col("implied_vol") > 0) & (pl.col("implied_vol") < 2.0) # Filter outliers ) .with_columns((pl.col("strike") / pl.col("underlying_price")).alias("moneyness")) .filter(pl.col("moneyness").is_between(0.7, 1.3)) # Focus on tradeable range ) # Create pivot for heatmap heatmap_data = ( aapl_calls.select(["strike", "expiration", "implied_vol"]) .sort(["expiration", "strike"]) .to_pandas() .pivot(index="strike", columns="expiration", values="implied_vol") ) # %% fig = go.Figure( data=go.Heatmap( z=heatmap_data.values, x=[str(c) for c in heatmap_data.columns], y=heatmap_data.index, colorscale="Viridis", colorbar=dict(title="IV"), ) ) spot_float = float(spot) fig.add_hline(y=spot_float, line_dash="dash", line_color="white") fig.add_annotation( y=spot_float, x=0.95, xref="paper", text=f"Spot: ${spot_float:.0f}", showarrow=False ) fig.update_layout( title=f"AAPL Option Chain - Implied Volatility Surface ({sample_date})", xaxis_title="Expiration", yaxis_title="Strike ($)", height=600, ) fig.show() # %% [markdown] # **Reading the heatmap:** # - Horizontal slice at one strike → Term structure (how IV varies with expiration) # - Vertical slice at one expiration → IV smile/skew (how IV varies with strike) # - Darker colors (lower IV) typically at ATM; lighter (higher IV) at wings # %% [markdown] # ## 4. Volatility Surface Analysis # # The **implied volatility surface** is the core representation for options analytics. # It captures how IV varies across two dimensions: # 1. **Moneyness** (strike relative to spot) → IV smile/skew # 2. **Time to expiration** → IV term structure # # ### 4.1 IV Smile and Skew # %% # IV smile for nearest expiration nearest_exp = aapl_calls["expiration"].min() aapl_smile = aapl_calls.filter(pl.col("expiration") == nearest_exp).sort("strike") fig = px.scatter( aapl_smile.to_pandas(), x="moneyness", y="implied_vol", title=f"AAPL IV Smile - Nearest Expiration ({nearest_exp})", labels={"moneyness": "Moneyness (Strike/Spot)", "implied_vol": "Implied Volatility"}, trendline="lowess", ) fig.add_vline(x=1.0, line_dash="dash", line_color="gray") fig.add_annotation(x=1.0, y=0.95, yref="paper", text="ATM", showarrow=False) fig.show() # %% [markdown] # **IV Smile/Skew interpretation:** # - **Smile**: IV higher for both OTM puts (left) and OTM calls (right) vs ATM # - **Skew**: Asymmetric - OTM puts typically have higher IV than OTM calls # - **Why?** Demand for downside protection (crash insurance) exceeds upside speculation # %% # Compare smile across multiple expirations expirations = sorted(aapl_calls["expiration"].unique().to_list())[:4] # First 4 smile_data = aapl_calls.filter(pl.col("expiration").is_in(expirations)) fig = px.scatter( smile_data.to_pandas(), x="moneyness", y="implied_vol", color="expiration", title="AAPL IV Smile Across Expirations", labels={"moneyness": "Moneyness", "implied_vol": "Implied Volatility"}, ) fig.add_vline(x=1.0, line_dash="dash", line_color="gray") fig.show() # %% [markdown] # ### 4.2 IV Term Structure # # How does ATM IV vary across expirations? # %% # ATM IV term structure (moneyness 0.98-1.02) atm_term = ( aapl_calls.filter(pl.col("moneyness").is_between(0.98, 1.02)) .group_by("expiration") .agg( [ pl.col("implied_vol").mean().alias("iv_atm"), pl.col("days_to_maturity").first().alias("days"), ] ) .sort("expiration") ) fig = px.line( atm_term.to_pandas(), x="days", y="iv_atm", markers=True, title=f"AAPL ATM IV Term Structure ({sample_date})", labels={"days": "Days to Expiration", "iv_atm": "ATM Implied Volatility"}, ) fig.show() # %% [markdown] # **Term structure shapes:** # - **Contango** (upward sloping): Near-term calm, uncertainty further out # - **Backwardation** (downward sloping): Near-term stress/event expected # - **Flat**: Consistent expectations across horizons # %% [markdown] # ### 4.3 3D Volatility Surface # # Combine moneyness and time dimensions into a single surface visualization. # %% # Prepare surface data surface_data = ( aapl_calls.filter(pl.col("days_to_maturity") <= 180) # Focus on <6 months .select(["moneyness", "days_to_maturity", "implied_vol"]) .to_pandas() ) # Create 3D surface fig = go.Figure( data=[ go.Mesh3d( x=surface_data["moneyness"], y=surface_data["days_to_maturity"], z=surface_data["implied_vol"], intensity=surface_data["implied_vol"], colorscale="Viridis", opacity=0.7, ) ] ) fig.update_layout( title=f"AAPL 3D Volatility Surface ({sample_date})", scene=dict( xaxis_title="Moneyness", yaxis_title="Days to Expiration", zaxis_title="Implied Volatility", ), height=600, ) fig.show() # %% [markdown] # ## 5. Cross-Sectional Analysis # # How does ATM IV differ across the eight underlyings on a single day? The same # logic scales to the full S&P 500 universe — here we keep the comparison # tractable on the EDA slice. # %% # Compute ATM IV for all symbols on sample date converged = options.filter(pl.col("iv_convergence") == "Converged") cross_section = ( converged.filter(pl.col("timestamp") == sample_date) .with_columns((pl.col("strike") / pl.col("underlying_price")).alias("moneyness")) .filter(pl.col("moneyness").is_between(0.98, 1.02)) .filter(pl.col("call_put") == "C") .group_by("symbol") .agg( [ pl.col("implied_vol").mean().alias("iv_atm"), pl.col("underlying_price").first().alias("price"), ] ) .sort("iv_atm", descending=True) ) print(f"=== Cross-Sectional ATM IV ({sample_date}) ===") print(f"Symbols: {len(cross_section)}") print(f"IV range: {cross_section['iv_atm'].min():.1%} - {cross_section['iv_atm'].max():.1%}") print(f"IV median: {cross_section['iv_atm'].median():.1%}") # %% # Highest IV names print("\n=== Highest IV Names ===") cross_section.head(10) # %% # Lowest IV names print("\n=== Lowest IV Names ===") cross_section.tail(10) # %% # IV distribution across universe fig = px.histogram( cross_section.to_pandas(), x="iv_atm", nbins=40, title=f"Cross-Sectional ATM IV Distribution ({sample_date})", labels={"iv_atm": "ATM Implied Volatility", "count": "Number of Symbols"}, ) median_iv = float(cross_section["iv_atm"].median()) fig.add_vline(x=median_iv, line_dash="dash", line_color="red") fig.add_annotation(x=median_iv, y=0.95, yref="paper", text="Median", showarrow=False) fig.show() # %% [markdown] # ## 6. Time Series Analysis # # How does IV evolve over time? The year 2020 provides an excellent case study # with the COVID crash in March. # %% # Daily aggregate IV statistics daily_iv = ( converged.with_columns((pl.col("strike") / pl.col("underlying_price")).alias("moneyness")) .filter(pl.col("moneyness").is_between(0.98, 1.02)) .filter(pl.col("call_put") == "C") .group_by("timestamp") .agg( [ pl.col("implied_vol").mean().alias("iv_mean"), pl.col("implied_vol").median().alias("iv_median"), pl.col("implied_vol").quantile(0.1).alias("iv_p10"), pl.col("implied_vol").quantile(0.9).alias("iv_p90"), pl.col("symbol").n_unique().alias("n_symbols"), ] ) .sort("timestamp") ) # %% # Build the full IV-evolution figure in one cell — splitting the figure across # two cells produced an intermediate render with no title or axis labels. fig = go.Figure() fig.add_trace( go.Scatter( x=daily_iv["timestamp"].to_list(), y=daily_iv["iv_p90"].to_list(), fill=None, mode="lines", line_color="lightblue", name="P90", ) ) fig.add_trace( go.Scatter( x=daily_iv["timestamp"].to_list(), y=daily_iv["iv_p10"].to_list(), fill="tonexty", mode="lines", line_color="lightblue", name="P10-P90 Range", ) ) fig.add_trace( go.Scatter( x=daily_iv["timestamp"].to_list(), y=daily_iv["iv_median"].to_list(), mode="lines", line_color="darkblue", line_width=2, name="Median IV", ) ) fig.add_vline(x="2020-03-16", line_dash="dash", line_color="red") fig.add_vline(x="2020-03-23", line_dash="dash", line_color="green") fig.add_annotation(x="2020-03-16", y=0.95, yref="paper", text="COVID Low", showarrow=False) fig.add_annotation(x="2020-03-23", y=0.90, yref="paper", text="Market Bottom", showarrow=False) fig.update_layout( title="S&P 500 Universe ATM IV Evolution (2020)", xaxis_title="Date", yaxis_title="Implied Volatility", height=500, ) fig.show() # %% [markdown] # **Key observations:** # - The 90th-percentile ATM IV crossed 100% in mid-March 2020 (peak P90 ≈ 115% on # 2020-03-16); cross-sectional median ATM IV peaked near 70%. # - IV stayed materially above pre-crash levels well past Q1 — slow mean reversion # even as spot recovered. # - The "fear gauge" aspect of IV — sharp spike, slow decay — is visible even on # this eight-symbol slice. # %% [markdown] # ## 7. Execution Cost Proxy: Bid-Ask Spreads # # Since we don't have volume or open interest, bid-ask spread serves as our # primary liquidity/execution cost indicator. # %% # Compute spread metrics spread_analysis = ( converged.with_columns( [ (pl.col("ask") - pl.col("bid")).alias("spread_abs"), ((pl.col("ask") - pl.col("bid")) / pl.col("mid_price")).alias("spread_pct"), (pl.col("strike") / pl.col("underlying_price")).alias("moneyness"), ] ) .filter(pl.col("mid_price") > 0.10) # Filter penny options .filter(pl.col("spread_pct") < 2.0) # Filter outliers ) print("=== Bid-Ask Spread Statistics ===") spread_analysis.select(["spread_abs", "spread_pct"]).describe() # %% # Spread by moneyness spread_by_moneyness = ( spread_analysis.filter(pl.col("moneyness").is_between(0.8, 1.2)) .with_columns((pl.col("moneyness") * 20).round() / 20) # Bucket to 5% increments .group_by("moneyness") .agg([pl.col("spread_pct").median().alias("median_spread")]) .sort("moneyness") ) fig = px.bar( spread_by_moneyness.to_pandas(), x="moneyness", y="median_spread", title="Median Bid-Ask Spread by Moneyness", labels={"moneyness": "Moneyness", "median_spread": "Median Spread (%)"}, ) fig.add_vline(x=1.0, line_dash="dash", line_color="gray") fig.add_annotation(x=1.0, y=0.95, yref="paper", text="ATM", showarrow=False) fig.show() # %% [markdown] # **Spread observations:** # - ATM options have tightest spreads (most liquid) # - Spreads widen for OTM options (less liquid) # - Deep OTM options can have very wide spreads (>50%) # - **Implication**: Focus on near-ATM for tradeable strategies # %% [markdown] # ## 8. Data Quality Assessment # # ### 8.1 IV Convergence Rates # %% # Convergence statistics convergence_stats = ( options.group_by("iv_convergence") .len() .with_columns((pl.col("len") / pl.sum("len") * 100).alias("pct")) .sort("len", descending=True) ) print("=== IV Convergence Status ===") for row in convergence_stats.iter_rows(named=True): print(f" {row['iv_convergence']}: {row['len']:,} ({row['pct']:.2f}%)") # %% # Horizontal bar chart instead of a pie: 10 convergence categories make a pie # unreadable (tiny slices overlap their labels). The bar chart sorts by share # and keeps every label legible. convergence_pd = convergence_stats.to_pandas().sort_values("pct", ascending=True) fig = go.Figure( data=go.Bar( x=convergence_pd["pct"], y=convergence_pd["iv_convergence"], orientation="h", text=[f"{v:.2f}%" for v in convergence_pd["pct"]], textposition="outside", marker_color="#4C72B0", ) ) fig.update_layout( title="IV Convergence Status Distribution", xaxis_title="Share of rows (%)", yaxis_title="Convergence status", template="plotly_white", height=460, margin=dict(l=170, r=100), xaxis=dict(range=[0, max(convergence_pd["pct"]) * 1.15]), ) fig.show() # %% [markdown] # ### 8.2 Coverage Analysis # %% # Daily symbol coverage daily_coverage = ( converged.group_by("timestamp") .agg( [ pl.col("symbol").n_unique().alias("n_symbols"), pl.len().alias("n_options"), ] ) .sort("timestamp") ) print("=== Daily Coverage (Converged Options) ===") print(f"Mean symbols/day: {daily_coverage['n_symbols'].mean():.0f}") print(f"Min symbols/day: {daily_coverage['n_symbols'].min()}") print(f"Max symbols/day: {daily_coverage['n_symbols'].max()}") # %% fig = make_subplots( rows=2, cols=1, shared_xaxes=True, subplot_titles=("Symbols with Converged Options", "Total Converged Options"), ) coverage_pd = daily_coverage.to_pandas() fig.add_trace( go.Scatter(x=coverage_pd["timestamp"], y=coverage_pd["n_symbols"], name="Symbols"), row=1, col=1, ) fig.add_trace( go.Scatter(x=coverage_pd["timestamp"], y=coverage_pd["n_options"], name="Options"), row=2, col=1, ) fig.update_layout(height=500, title="Options Universe Coverage Over Time") fig.show() # %% [markdown] # ### 8.3 Greeks Validation # %% print("=== Greeks Validity Checks ===") checks = converged.select( [ # Delta should be [-1, 1] ((pl.col("delta") >= -1.0) & (pl.col("delta") <= 1.0)).mean().alias("delta_in_bounds"), # Gamma should be non-negative (pl.col("gamma") >= 0).mean().alias("gamma_non_negative"), # Vega should be non-negative (pl.col("vega") >= 0).mean().alias("vega_non_negative"), # Theta typically negative (pl.col("theta") <= 0.01).mean().alias("theta_typical"), # IV should be positive (pl.col("implied_vol") > 0).mean().alias("iv_positive"), ] ) for col in checks.columns: pct = checks[col][0] * 100 status = "PASS" if pct > 99.9 else ("WARN" if pct > 95 else "FAIL") print(f" [{status}] {col}: {pct:.2f}%") # %% print("\n=== Greeks Summary Statistics ===") converged.select(["delta", "gamma", "theta", "vega", "implied_vol"]).describe() # %% [markdown] # ### 8.4 Point-in-Time Validation # %% print("=== Point-in-Time Checks ===") # Expiration must be >= observation date exp_check = options.filter(pl.col("expiration") < pl.col("timestamp")) print(f"Expiration < Date violations: {len(exp_check):,}") # Days to maturity must be non-negative dtm_check = options.filter(pl.col("days_to_maturity") < 0) print(f"Negative days_to_maturity: {len(dtm_check):,}") if len(exp_check) == 0 and len(dtm_check) == 0: print("[OK] PASSED - No look-ahead bias detected") else: print("[FAIL] FAILED - Data integrity issue") # %% [markdown] # ## 9. Information Content Preview # # Why does options data predict underlying returns? Let's examine the IV-return # relationship. # %% # Compute ATM IV per symbol/date atm_iv = ( converged.with_columns((pl.col("strike") / pl.col("underlying_price")).alias("moneyness")) .filter(pl.col("moneyness").is_between(0.98, 1.02)) .filter(pl.col("call_put") == "C") .with_columns((pl.col("moneyness") - 1.0).abs().alias("atm_distance")) .sort(["timestamp", "symbol", "atm_distance"]) .group_by(["timestamp", "symbol"]) .first() .select(["timestamp", "symbol", "implied_vol", "underlying_price"]) .rename({"implied_vol": "iv_atm"}) ) # Join with daily prices and compute returns panel = ( atm_iv.join( daily.select(["timestamp", "symbol", "close"]), on=["timestamp", "symbol"], how="inner" ) .sort(["symbol", "timestamp"]) .with_columns( [ pl.col("iv_atm").shift(5).over("symbol").alias("iv_atm_lag5"), pl.col("close").shift(-5).over("symbol").alias("close_fwd5"), ] ) .with_columns( [ (pl.col("iv_atm") - pl.col("iv_atm_lag5")).alias("iv_change_5d"), ((pl.col("close_fwd5") / pl.col("close")) - 1).alias("ret_fwd5"), ] ) .drop_nulls(subset=["iv_change_5d", "ret_fwd5"]) ) # Correlation correlation = panel.select(pl.corr("iv_change_5d", "ret_fwd5").alias("corr"))[0, 0] print("=== IV Change vs Forward Return ===") print(f"Correlation: {correlation:.4f}") print("Interpretation: Falling IV tends to precede positive returns") # %% # Quintile analysis panel_ranked = ( panel.with_columns(pl.col("iv_change_5d").rank().over("timestamp").alias("iv_rank_raw")) .with_columns( (pl.col("iv_rank_raw") / pl.col("iv_rank_raw").max().over("timestamp") * 100).alias( "iv_pct" ) ) .with_columns( pl.when(pl.col("iv_pct") <= 20) .then(1) .when(pl.col("iv_pct") <= 40) .then(2) .when(pl.col("iv_pct") <= 60) .then(3) .when(pl.col("iv_pct") <= 80) .then(4) .otherwise(5) .alias("iv_quintile") ) ) quintile_returns = ( panel_ranked.group_by("iv_quintile") .agg( [ pl.col("ret_fwd5").mean().alias("mean_ret"), pl.col("ret_fwd5").std().alias("std_ret"), pl.len().alias("n_obs"), ] ) .sort("iv_quintile") ) print("\n=== Forward Returns by IV Change Quintile ===") print("(Q1 = largest IV decrease, Q5 = largest IV increase)") quintile_returns # %% fig = px.bar( quintile_returns.to_pandas(), x="iv_quintile", y="mean_ret", title="5-Day Forward Returns by IV Change Quintile", labels={ "iv_quintile": "IV Change Quintile (1=falling, 5=rising)", "mean_ret": "Mean 5-Day Return", }, ) fig.update_layout(xaxis=dict(tickmode="array", tickvals=[1, 2, 3, 4, 5])) fig.show() # %% [markdown] # The sign of the −0.08 IV-change vs forward-return correlation is negative: on # this 8-symbol 2020 slice, larger IV declines line up with higher 5-day forward # returns and larger IV increases with lower forward returns. The quintile means # above show how monotonic the relationship is. This notebook does not test # statistical significance or out-of-sample stability; the IV-based feature is # evaluated rigorously via IC analysis in Chapter 9. # %% [markdown] # ## 10. Data Quality Summary # %% total_rows = len(options) converged_rows = len(converged) converged_pct = converged_rows / total_rows * 100 print("=" * 70) print("DATA QUALITY SUMMARY: S&P 500 OPTIONS") print("=" * 70) print("\n1. SCALE") print(f" Total options records: {total_rows:,}") print(f" Converged IV records: {converged_rows:,} ({converged_pct:.1f}%)") print(f" Unique underlyings: {options['symbol'].n_unique()}") print(f" Date range: {options['timestamp'].min()} to {options['timestamp'].max()}") print("\n2. COVERAGE") print(f" Trading days: {daily_coverage['timestamp'].n_unique()}") print(f" Avg symbols/day: {daily_coverage['n_symbols'].mean():.0f}") print(f" Avg options/symbol/day: {options_per_symbol['n_options'].mean():.0f}") print("\n3. DATA QUALITY") print(f" Point-in-time: {'PASS' if len(exp_check) == 0 else 'FAIL'}") print(" Greeks validity: See checks above") print(f" Convergence rate: {converged_pct:.1f}%") print("\n4. EXECUTION PROXY") print( f" Median ATM spread: {spread_by_moneyness.filter(pl.col('moneyness') == 1.0)['median_spread'][0]:.1%}" ) print("\n5. INFORMATION CONTENT") print(f" IV-Return Correlation: {correlation:.4f}") print("\n" + "=" * 70) print("DATASET SUPPORTS TWO CASE STUDIES:") print(" sp500_equity_option_analytics — IV features used to trade equities") print(" sp500_options — short-straddle harvest with daily delta hedge") print("=" * 70) # %% [markdown] # ## Key Takeaways # # 1. **Scale (EDA slice)**: ~5.2M option records across 8 representative S&P 500 # underlyings in 2020. The full AlgoSeek panel covers the broader index — this # notebook intentionally subsamples for fast exploration. # 2. **Structure**: Each underlying carries dozens of expirations and 100+ # strikes per day; chains are 3-D grids (strike × expiration × call/put). # 3. **Quality**: 69.6% of rows carry `iv_convergence == "Converged"`. The other # convergence codes flag extrapolated or solver-failed IVs and should be # excluded from analysis. # 4. **Greeks**: Pre-computed Black-Scholes sensitivities are well-bounded — Δ in # [-1, 1], Γ ≥ 0, ν ≥ 0, θ ≤ 0 — at >99.9% of converged rows. # 5. **Volatility Surface**: Smile, skew, and term structure are all visible on # this slice; the 3-D surface compresses both axes into one plot. # 6. **Information**: 5-day IV changes correlate with 5-day forward equity # returns at -0.08 on this sample (falling IV → positive returns), motivating # the IV-based features in Chapter 8. # 7. **Execution**: Median ATM bid-ask spread is ~6%; deep-OTM spreads widen # sharply, which constrains tradeable strategies to near-the-money strikes. # # ## Data Limitations # # - **No volume/open interest**: Cannot filter for liquidity directly. # - **Daily snapshots only**: No intraday dynamics for gamma scalping. # - **Spread as proxy**: Bid-ask is the only execution cost indicator available. # # ## Next Steps # # - `08_options_greeks_computation`: Compute Greeks from scratch and validate # against the vendor numbers used here. # - Chapter 8: Build IV surface features for ML. # - Chapter 9: Model-based feature extraction (PCA, autoencoders). # - Chapter 12: ML models for equity and options targets. # - Chapter 16: Backtests using these features.