""" 3D probability surface visualization. Create interactive 3D surface plots showing Strike × Time-to-Expiry × Probability. """ import numpy as np import plotly.graph_objects as go from typing import Dict, List, Optional from src.visualization.themes import ( create_base_layout, create_3d_scene_config, DARK_THEME, COLORSCALES ) def create_3d_surface( pdf_data: Dict[str, Dict[str, np.ndarray]], spot_price: Optional[float] = None, title: str = "SPX Option-Implied Probability Surface", colorscale: str = 'Viridis', show_contours: bool = True ) -> go.Figure: """ Create 3D probability surface from multiple expiration PDFs. Args: pdf_data: Dictionary with format: { 'expiration_date': { 'strikes': np.ndarray, 'pdf': np.ndarray, 'days_to_expiry': int } } spot_price: Current spot price (optional, for marker) title: Plot title colorscale: Plotly colorscale name show_contours: Whether to show contour lines Returns: Plotly 3D figure """ if len(pdf_data) < 2: raise ValueError("Need at least 2 expirations for 3D surface") # Sort by days to expiry sorted_data = sorted( pdf_data.items(), key=lambda x: x[1]['days_to_expiry'] ) # Find common strike range all_strikes = [data['strikes'] for _, data in sorted_data] min_strike = max(strikes.min() for strikes in all_strikes) max_strike = min(strikes.max() for strikes in all_strikes) # Create uniform strike grid strike_grid = np.linspace(min_strike, max_strike, 100) # Prepare data for surface expiry_days = [] pdf_matrix = [] for exp_date, data in sorted_data: days = data['days_to_expiry'] strikes = data['strikes'] pdf = data['pdf'] # Interpolate to uniform grid pdf_interp = np.interp(strike_grid, strikes, pdf) expiry_days.append(days) pdf_matrix.append(pdf_interp) # Convert to 2D arrays X = strike_grid # Strikes Y = np.array(expiry_days) # Days to expiry Z = np.array(pdf_matrix) # PDF values # Create meshgrid X_mesh, Y_mesh = np.meshgrid(X, Y) # Create figure fig = go.Figure() # Add surface contours_config = {} if show_contours: contours_config = { 'z': { 'show': True, 'usecolormap': True, 'highlightcolor': "limegreen", 'project': {'z': True} } } fig.add_trace(go.Surface( x=X_mesh, y=Y_mesh, z=Z, colorscale=colorscale, opacity=0.9, name='Probability Density', contours=contours_config, hovertemplate=( 'Strike: $%{x:.2f}
' 'Days to Expiry: %{y:.0f}
' 'Probability Density: %{z:.6f}
' '' ) )) # Add spot price marker (vertical line) if provided if spot_price is not None: # Create vertical line at spot price z_line = np.linspace(0, Z.max(), 10) y_line = np.full_like(z_line, Y.min()) x_line = np.full_like(z_line, spot_price) fig.add_trace(go.Scatter3d( x=x_line, y=y_line, z=z_line, mode='lines', name=f'Spot: ${spot_price:.2f}', line=dict( color=DARK_THEME['success'], width=5, dash='dash' ), showlegend=True )) # Configure 3D scene scene = create_3d_scene_config( xaxis_title="Strike Price ($)", yaxis_title="Days to Expiration", zaxis_title="Probability Density" ) # Layout layout = create_base_layout( title=title, showlegend=True, scene=scene, legend=dict( x=0.02, y=0.98, bgcolor='rgba(20,20,20,0.8)', bordercolor=DARK_THEME['grid'], borderwidth=1 ) ) fig.update_layout(**layout) # Set camera angle for better view fig.update_layout( scene_camera=dict( eye=dict(x=1.5, y=1.5, z=1.3), center=dict(x=0, y=0, z=-0.1) ) ) return fig def create_heatmap_2d( pdf_data: Dict[str, Dict[str, np.ndarray]], spot_price: Optional[float] = None, title: str = "Probability Density Heatmap", colorscale: str = 'Viridis' ) -> go.Figure: """ Create 2D heatmap of probability density (alternative to 3D surface). Args: pdf_data: PDF data for multiple expirations spot_price: Current spot price title: Plot title colorscale: Plotly colorscale name Returns: Plotly figure """ if len(pdf_data) < 2: raise ValueError("Need at least 2 expirations for heatmap") # Sort by days to expiry sorted_data = sorted( pdf_data.items(), key=lambda x: x[1]['days_to_expiry'] ) # Find common strike range all_strikes = [data['strikes'] for _, data in sorted_data] min_strike = max(strikes.min() for strikes in all_strikes) max_strike = min(strikes.max() for strikes in all_strikes) # Create uniform strike grid strike_grid = np.linspace(min_strike, max_strike, 100) # Prepare data expiry_labels = [] pdf_matrix = [] for exp_date, data in sorted_data: days = data['days_to_expiry'] strikes = data['strikes'] pdf = data['pdf'] # Interpolate to uniform grid pdf_interp = np.interp(strike_grid, strikes, pdf) expiry_labels.append(f"{days}D") pdf_matrix.append(pdf_interp) # Transpose for correct orientation Z = np.array(pdf_matrix) # Create heatmap fig = go.Figure() fig.add_trace(go.Heatmap( x=strike_grid, y=expiry_labels, z=Z, colorscale=colorscale, colorbar=dict( title="Probability
Density", titleside="right" ), hovertemplate=( 'Strike: $%{x:.2f}
' 'Expiration: %{y}
' 'Probability: %{z:.6f}
' '' ) )) # Add spot price line if spot_price is not None: fig.add_vline( x=spot_price, line_dash="dash", line_color=DARK_THEME['success'], line_width=3, annotation_text=f"Spot: ${spot_price:.2f}", annotation_position="top" ) # Layout layout = create_base_layout( title=title, xaxis_title="Strike Price ($)", yaxis_title="Days to Expiration" ) fig.update_layout(**layout) return fig def create_wireframe_3d( pdf_data: Dict[str, Dict[str, np.ndarray]], spot_price: Optional[float] = None, title: str = "Probability Wireframe", line_color: str = None ) -> go.Figure: """ Create 3D wireframe plot (lighter alternative to surface). Args: pdf_data: PDF data for multiple expirations spot_price: Current spot price title: Plot title line_color: Line color (default: cyan) Returns: Plotly figure """ if line_color is None: line_color = DARK_THEME['primary'] # Sort by days to expiry sorted_data = sorted( pdf_data.items(), key=lambda x: x[1]['days_to_expiry'] ) fig = go.Figure() # Add each expiration as a 3D line for idx, (exp_date, data) in enumerate(sorted_data): strikes = data['strikes'] pdf = data['pdf'] days = data['days_to_expiry'] # Create y-values (all same for this expiration) y_vals = np.full_like(strikes, days) fig.add_trace(go.Scatter3d( x=strikes, y=y_vals, z=pdf, mode='lines', name=f"{days}D", line=dict( color=line_color, width=2 ), hovertemplate=( f'Strike: %{{x:.2f}}
' f'Days: {days}
' f'PDF: %{{z:.6f}}
' '' ) )) # Configure 3D scene scene = create_3d_scene_config( xaxis_title="Strike Price ($)", yaxis_title="Days to Expiration", zaxis_title="Probability Density" ) # Layout layout = create_base_layout( title=title, showlegend=True, scene=scene, legend=dict( x=0.02, y=0.98, bgcolor='rgba(20,20,20,0.8)', bordercolor=DARK_THEME['grid'], borderwidth=1 ) ) fig.update_layout(**layout) # Set camera angle fig.update_layout( scene_camera=dict( eye=dict(x=1.5, y=1.5, z=1.3) ) ) return fig if __name__ == "__main__": # Test 3D surface plots print("Testing 3D probability surface...") # Create synthetic PDF data for multiple expirations from scipy.stats import norm spot = 450.0 strikes_base = np.linspace(400, 500, 100) # Create PDFs for different expirations pdf_data = {} expirations = [ ('2025-01-15', 15), ('2025-02-01', 30), ('2025-02-15', 45), ('2025-03-01', 60) ] for exp_date, days in expirations: # Adjust std based on time (more spread for longer dated) std = 10 * np.sqrt(days / 30) pdf = norm.pdf(strikes_base, loc=spot, scale=std) pdf_data[exp_date] = { 'strikes': strikes_base, 'pdf': pdf, 'days_to_expiry': days } # Test 3D surface fig1 = create_3d_surface(pdf_data, spot_price=spot) fig1.write_html("test_3d_surface.html") print("✅ 3D surface saved to test_3d_surface.html") # Test heatmap fig2 = create_heatmap_2d(pdf_data, spot_price=spot) fig2.write_html("test_heatmap.html") print("✅ Heatmap saved to test_heatmap.html") # Test wireframe fig3 = create_wireframe_3d(pdf_data, spot_price=spot) fig3.write_html("test_wireframe.html") print("✅ Wireframe saved to test_wireframe.html") print("\n✅ All 3D visualization tests passed!")