voly 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
voly/core/charts.py ADDED
@@ -0,0 +1,984 @@
1
+ """
2
+ Visualization module for the Voly package.
3
+
4
+ This module provides visualization functions for volatility surfaces,
5
+ risk-neutral densities, and model fitting results.
6
+ """
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from typing import Dict, List, Tuple, Optional, Union, Any
11
+ import plotly.graph_objects as go
12
+ from plotly.subplots import make_subplots
13
+ import plotly.io as pio
14
+ from voly.utils.logger import logger, catch_exception
15
+ from voly.models import SVIModel
16
+
17
+ # Set default renderer to browser for interactive plots
18
+ pio.renderers.default = "browser"
19
+
20
+
21
+ @catch_exception
22
+ def plot_volatility_smile(moneyness: np.ndarray,
23
+ iv: np.ndarray,
24
+ market_data: Optional[pd.DataFrame] = None,
25
+ expiry: Optional[float] = None,
26
+ title: Optional[str] = None) -> go.Figure:
27
+ """
28
+ Plot volatility smile for a single expiry.
29
+
30
+ Parameters:
31
+ - moneyness: Moneyness grid
32
+ - iv: Implied volatility values
33
+ - market_data: Optional market data for comparison
34
+ - expiry: Optional expiry time (in years) for filtering market data
35
+ - title: Optional plot title
36
+
37
+ Returns:
38
+ - Plotly figure
39
+ """
40
+ fig = go.Figure()
41
+
42
+ # Add model curve
43
+ fig.add_trace(
44
+ go.Scatter(
45
+ x=moneyness,
46
+ y=iv * 100, # Convert to percentage
47
+ mode='lines',
48
+ name='Model',
49
+ line=dict(color='#00FFC1', width=2)
50
+ )
51
+ )
52
+
53
+ # Add market data if provided
54
+ if market_data is not None and expiry is not None:
55
+ # Filter market data for the specific expiry
56
+ expiry_data = market_data[market_data['yte'] == expiry]
57
+
58
+ if not expiry_data.empty:
59
+ # Add bid IV
60
+ fig.add_trace(
61
+ go.Scatter(
62
+ x=expiry_data['log_moneyness'],
63
+ y=expiry_data['bid_iv'] * 100, # Convert to percentage
64
+ mode='markers',
65
+ name='Bid IV',
66
+ marker=dict(size=8, symbol='circle', opacity=0.7)
67
+ )
68
+ )
69
+
70
+ # Add ask IV
71
+ fig.add_trace(
72
+ go.Scatter(
73
+ x=expiry_data['log_moneyness'],
74
+ y=expiry_data['ask_iv'] * 100, # Convert to percentage
75
+ mode='markers',
76
+ name='Ask IV',
77
+ marker=dict(size=8, symbol='circle', opacity=0.7)
78
+ )
79
+ )
80
+
81
+ # Get maturity name and DTE for title if not provided
82
+ if title is None:
83
+ maturity_name = expiry_data['maturity_name'].iloc[0]
84
+ dte_value = expiry_data['dte'].iloc[0]
85
+ title = f'Volatility Smile for {maturity_name} (DTE: {dte_value:.1f}, YTE: {expiry:.4f})'
86
+
87
+ # Use default title if not provided
88
+ if title is None:
89
+ title = 'Volatility Smile'
90
+
91
+ # Update layout
92
+ fig.update_layout(
93
+ title=title,
94
+ xaxis_title='Log Moneyness',
95
+ yaxis_title='Implied Volatility (%)',
96
+ template='plotly_dark',
97
+ legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1)
98
+ )
99
+
100
+ return fig
101
+
102
+
103
+ @catch_exception
104
+ def plot_all_smiles(moneyness: np.ndarray,
105
+ iv_surface: Dict[float, np.ndarray],
106
+ market_data: Optional[pd.DataFrame] = None) -> List[go.Figure]:
107
+ """
108
+ Plot volatility smiles for all expiries.
109
+
110
+ Parameters:
111
+ - moneyness: Moneyness grid
112
+ - iv_surface: Dictionary mapping expiry times to IV arrays
113
+ - market_data: Optional market data for comparison
114
+
115
+ Returns:
116
+ - List of Plotly figures
117
+ """
118
+ figures = []
119
+
120
+ # Sort expiries in ascending order
121
+ sorted_expiries = sorted(iv_surface.keys())
122
+
123
+ # Create a figure for each expiry
124
+ for expiry in sorted_expiries:
125
+ fig = plot_volatility_smile(
126
+ moneyness=moneyness,
127
+ iv=iv_surface[expiry],
128
+ market_data=market_data,
129
+ expiry=expiry
130
+ )
131
+ figures.append(fig)
132
+
133
+ return figures
134
+
135
+
136
+ @catch_exception
137
+ def plot_parameters(raw_param_matrix: pd.DataFrame,
138
+ jw_param_matrix: Optional[pd.DataFrame] = None) -> Tuple[go.Figure, Optional[go.Figure]]:
139
+ """
140
+ Plot model parameters across different expiries.
141
+
142
+ Parameters:
143
+ - raw_param_matrix: Matrix of raw SVI parameters with maturity names as columns
144
+ - jw_param_matrix: Optional matrix of Jump-Wing parameters
145
+
146
+ Returns:
147
+ - Tuple of Plotly figures (raw_params_fig, jw_params_fig)
148
+ """
149
+ # Plot raw SVI parameters
150
+ param_names = raw_param_matrix.index
151
+ raw_fig = make_subplots(rows=3, cols=2, subplot_titles=[f"Parameter {p}: {SVIModel.PARAM_DESCRIPTIONS.get(p, '')}"
152
+ for p in param_names] + [''])
153
+
154
+ # Get maturity names (columns) in order
155
+ maturity_names = raw_param_matrix.columns
156
+
157
+ # Get YTE and DTE values from attributes
158
+ yte_values = raw_param_matrix.attrs['yte_values']
159
+ dte_values = raw_param_matrix.attrs['dte_values']
160
+
161
+ # Create custom x-axis tick labels
162
+ tick_labels = [f"{m} (DTE: {dte_values[m]:.1f}, YTE: {yte_values[m]:.4f})" for m in maturity_names]
163
+
164
+ # Plot each parameter
165
+ for i, param in enumerate(param_names):
166
+ row = i // 2 + 1
167
+ col = i % 2 + 1
168
+
169
+ raw_fig.add_trace(
170
+ go.Scatter(
171
+ x=list(range(len(maturity_names))), # Use indices for x-axis positioning
172
+ y=raw_param_matrix.loc[param],
173
+ mode='lines+markers',
174
+ name=param,
175
+ line=dict(width=2),
176
+ marker=dict(size=8),
177
+ text=tick_labels, # Add hover text
178
+ hovertemplate="%{text}<br>%{y:.4f}"
179
+ ),
180
+ row=row, col=col
181
+ )
182
+
183
+ # Update x-axis for this subplot with custom tick labels
184
+ raw_fig.update_xaxes(
185
+ tickvals=list(range(len(maturity_names))),
186
+ ticktext=maturity_names,
187
+ tickangle=45,
188
+ row=row, col=col
189
+ )
190
+
191
+ # Update layout for raw parameters
192
+ raw_fig.update_layout(
193
+ title='Raw SVI Parameters Across Expiries',
194
+ template='plotly_dark',
195
+ showlegend=False,
196
+ height=800
197
+ )
198
+
199
+ # Plot Jump-Wing parameters if provided
200
+ jw_fig = None
201
+ if jw_param_matrix is not None:
202
+ jw_param_names = jw_param_matrix.index
203
+ jw_fig = make_subplots(rows=3, cols=2,
204
+ subplot_titles=[f"Parameter {p}: {SVIModel.PARAM_DESCRIPTIONS.get(p, '')}"
205
+ for p in jw_param_names] + [''])
206
+
207
+ # Plot each JW parameter
208
+ for i, param in enumerate(jw_param_names):
209
+ row = i // 2 + 1
210
+ col = i % 2 + 1
211
+
212
+ jw_fig.add_trace(
213
+ go.Scatter(
214
+ x=list(range(len(maturity_names))), # Use indices for x-axis positioning
215
+ y=jw_param_matrix.loc[param],
216
+ mode='lines+markers',
217
+ name=param,
218
+ line=dict(width=2, color='rgb(0, 180, 180)'),
219
+ marker=dict(size=8),
220
+ text=tick_labels, # Add hover text
221
+ hovertemplate="%{text}<br>%{y:.4f}"
222
+ ),
223
+ row=row, col=col
224
+ )
225
+
226
+ # Update x-axis for this subplot with custom tick labels
227
+ jw_fig.update_xaxes(
228
+ tickvals=list(range(len(maturity_names))),
229
+ ticktext=maturity_names,
230
+ tickangle=45,
231
+ row=row, col=col
232
+ )
233
+
234
+ # Update layout for JW parameters
235
+ jw_fig.update_layout(
236
+ title='Jump-Wing Parameters Across Expiries',
237
+ template='plotly_dark',
238
+ showlegend=False,
239
+ height=800
240
+ )
241
+
242
+ return raw_fig, jw_fig
243
+
244
+
245
+ @catch_exception
246
+ def plot_fit_statistics(stats_df: pd.DataFrame) -> go.Figure:
247
+ """
248
+ Plot the fitting accuracy statistics.
249
+
250
+ Parameters:
251
+ - stats_df: DataFrame with fitting statistics
252
+
253
+ Returns:
254
+ - Plotly figure
255
+ """
256
+ fig = make_subplots(
257
+ rows=2, cols=2,
258
+ subplot_titles=['RMSE by Expiry', 'MAE by Expiry',
259
+ 'R² by Expiry', 'Max Error by Expiry']
260
+ )
261
+
262
+ # Create custom tick labels with maturity name, DTE, and YTE
263
+ tick_labels = [f"{m} (DTE: {d:.1f}, YTE: {y:.4f})" for m, d, y in
264
+ zip(stats_df['Maturity'], stats_df['DTE'], stats_df['YTE'])]
265
+
266
+ # Get x-axis values for plotting (use indices for positioning)
267
+ x_indices = list(range(len(stats_df)))
268
+
269
+ # Plot RMSE
270
+ fig.add_trace(
271
+ go.Scatter(
272
+ x=x_indices,
273
+ y=stats_df['RMSE'] * 100, # Convert to percentage
274
+ mode='lines+markers',
275
+ name='RMSE',
276
+ line=dict(width=2),
277
+ marker=dict(size=8),
278
+ text=tick_labels # Add hover text
279
+ ),
280
+ row=1, col=1
281
+ )
282
+
283
+ # Plot MAE
284
+ fig.add_trace(
285
+ go.Scatter(
286
+ x=x_indices,
287
+ y=stats_df['MAE'] * 100, # Convert to percentage
288
+ mode='lines+markers',
289
+ name='MAE',
290
+ line=dict(width=2),
291
+ marker=dict(size=8),
292
+ text=tick_labels # Add hover text
293
+ ),
294
+ row=1, col=2
295
+ )
296
+
297
+ # Plot R²
298
+ fig.add_trace(
299
+ go.Scatter(
300
+ x=x_indices,
301
+ y=stats_df['R²'],
302
+ mode='lines+markers',
303
+ name='R²',
304
+ line=dict(width=2),
305
+ marker=dict(size=8),
306
+ text=tick_labels # Add hover text
307
+ ),
308
+ row=2, col=1
309
+ )
310
+
311
+ # Plot Max Error
312
+ fig.add_trace(
313
+ go.Scatter(
314
+ x=x_indices,
315
+ y=stats_df['Max Error'] * 100, # Convert to percentage
316
+ mode='lines+markers',
317
+ name='Max Error',
318
+ line=dict(width=2),
319
+ marker=dict(size=8),
320
+ text=tick_labels # Add hover text
321
+ ),
322
+ row=2, col=2
323
+ )
324
+
325
+ # Update x-axis for all subplots with maturity names
326
+ for row in range(1, 3):
327
+ for col in range(1, 3):
328
+ fig.update_xaxes(
329
+ tickvals=x_indices,
330
+ ticktext=stats_df['Maturity'],
331
+ tickangle=45,
332
+ row=row, col=col
333
+ )
334
+
335
+ # Update y-axis titles
336
+ fig.update_yaxes(title_text='RMSE (%)', row=1, col=1)
337
+ fig.update_yaxes(title_text='MAE (%)', row=1, col=2)
338
+ fig.update_yaxes(title_text='R²', row=2, col=1)
339
+ fig.update_yaxes(title_text='Max Error (%)', row=2, col=2)
340
+
341
+ # Update layout
342
+ fig.update_layout(
343
+ title='Model Fitting Accuracy Statistics',
344
+ template='plotly_dark',
345
+ showlegend=False
346
+ )
347
+
348
+ return fig
349
+
350
+
351
+ @catch_exception
352
+ def plot_3d_surface(moneyness: np.ndarray,
353
+ expiries: np.ndarray,
354
+ iv_surface: Dict[float, np.ndarray],
355
+ interpolate: bool = True,
356
+ title: str = 'Implied Volatility Surface') -> go.Figure:
357
+ """
358
+ Plot 3D implied volatility surface.
359
+
360
+ Parameters:
361
+ - moneyness: Moneyness grid
362
+ - expiries: Expiry times in years
363
+ - iv_surface: Dictionary mapping expiry times to IV arrays
364
+ - interpolate: Whether to interpolate the surface
365
+ - title: Plot title
366
+
367
+ Returns:
368
+ - Plotly figure
369
+ """
370
+ # Convert implied volatility surface to array
371
+ z_array = np.array([iv_surface[t] for t in expiries])
372
+
373
+ # Create mesh grid
374
+ X, Y = np.meshgrid(moneyness, expiries)
375
+ Z = z_array * 100 # Convert to percentage
376
+
377
+ # Create 3D surface plot
378
+ fig = go.Figure(data=[go.Surface(z=Z, x=X, y=Y, colorscale='tealgrn')])
379
+
380
+ # Add colorbar
381
+ fig.update_traces(contours_z=dict(show=True, usecolormap=True,
382
+ highlightcolor="limegreen", project_z=True))
383
+
384
+ # Update layout
385
+ fig.update_layout(
386
+ title=title,
387
+ template='plotly_dark',
388
+ scene=dict(
389
+ xaxis_title='Log Moneyness',
390
+ yaxis_title='Time to Expiry (years)',
391
+ zaxis_title='Implied Volatility (%)'
392
+ ),
393
+ margin=dict(l=65, r=50, b=65, t=90)
394
+ )
395
+
396
+ return fig
397
+
398
+
399
+ @catch_exception
400
+ def plot_rnd(moneyness_grid: np.ndarray,
401
+ rnd_values: np.ndarray,
402
+ spot_price: float = 1.0,
403
+ title: str = 'Risk-Neutral Density') -> go.Figure:
404
+ """
405
+ Plot risk-neutral density (RND).
406
+
407
+ Parameters:
408
+ - moneyness_grid: Grid of log-moneyness values
409
+ - rnd_values: RND values
410
+ - spot_price: Spot price for converting to absolute prices
411
+ - title: Plot title
412
+
413
+ Returns:
414
+ - Plotly figure
415
+ """
416
+ # Create figure
417
+ fig = go.Figure()
418
+
419
+ # Convert to prices and normalize RND
420
+ prices = spot_price * np.exp(moneyness_grid)
421
+
422
+ # Normalize the RND to integrate to 1
423
+ dx = moneyness_grid[1] - moneyness_grid[0]
424
+ total_density = np.sum(rnd_values) * dx
425
+ normalized_rnd = rnd_values / total_density if total_density > 0 else rnd_values
426
+
427
+ # Add trace
428
+ fig.add_trace(
429
+ go.Scatter(
430
+ x=prices,
431
+ y=normalized_rnd,
432
+ mode='lines',
433
+ name='RND',
434
+ line=dict(color='#00FFC1', width=2),
435
+ fill='tozeroy',
436
+ fillcolor='rgba(0, 255, 193, 0.2)'
437
+ )
438
+ )
439
+
440
+ # Add vertical line at spot price
441
+ fig.add_shape(
442
+ type='line',
443
+ x0=spot_price, y0=0,
444
+ x1=spot_price, y1=max(normalized_rnd) * 1.1,
445
+ line=dict(color='red', width=2, dash='dash')
446
+ )
447
+
448
+ # Add annotation for spot price
449
+ fig.add_annotation(
450
+ x=spot_price,
451
+ y=max(normalized_rnd) * 1.15,
452
+ text=f"Spot: {spot_price}",
453
+ showarrow=False,
454
+ font=dict(color='red')
455
+ )
456
+
457
+ # Update layout
458
+ fig.update_layout(
459
+ title=title,
460
+ xaxis_title='Price',
461
+ yaxis_title='Density',
462
+ template='plotly_dark',
463
+ showlegend=False
464
+ )
465
+
466
+ return fig
467
+
468
+
469
+ @catch_exception
470
+ def plot_rnd_all_expiries(moneyness_grid: np.ndarray,
471
+ rnd_surface: Dict[str, np.ndarray],
472
+ param_matrix: pd.DataFrame,
473
+ spot_price: float = 1.0) -> go.Figure:
474
+ """
475
+ Plot risk-neutral densities for all expiries.
476
+
477
+ Parameters:
478
+ - moneyness_grid: Grid of log-moneyness values
479
+ - rnd_surface: Dictionary mapping maturity names to RND arrays
480
+ - param_matrix: Matrix containing model parameters with maturity info
481
+ - spot_price: Spot price for converting to absolute prices
482
+
483
+ Returns:
484
+ - Plotly figure
485
+ """
486
+ # Get maturity information
487
+ dte_values = param_matrix.attrs['dte_values']
488
+
489
+ # Create figure
490
+ fig = go.Figure()
491
+
492
+ # Get maturity names in order by DTE
493
+ maturity_names = sorted(rnd_surface.keys(), key=lambda x: dte_values[x])
494
+
495
+ # Create color scale from purple to green
496
+ n_maturities = len(maturity_names)
497
+ colors = [f'rgb({int(255 - i * 255 / n_maturities)}, {int(i * 255 / n_maturities)}, 255)'
498
+ for i in range(n_maturities)]
499
+
500
+ # Convert to prices
501
+ prices = spot_price * np.exp(moneyness_grid)
502
+
503
+ # Add traces for each expiry
504
+ for i, maturity_name in enumerate(maturity_names):
505
+ rnd = rnd_surface[maturity_name]
506
+ dte = dte_values[maturity_name]
507
+
508
+ # Normalize the RND
509
+ dx = moneyness_grid[1] - moneyness_grid[0]
510
+ total_density = np.sum(rnd) * dx
511
+ normalized_rnd = rnd / total_density if total_density > 0 else rnd
512
+
513
+ # Add trace
514
+ fig.add_trace(
515
+ go.Scatter(
516
+ x=prices,
517
+ y=normalized_rnd,
518
+ mode='lines',
519
+ name=f"{maturity_name} (DTE: {dte:.1f})",
520
+ line=dict(color=colors[i], width=2),
521
+ )
522
+ )
523
+
524
+ # Add vertical line at spot price
525
+ fig.add_shape(
526
+ type='line',
527
+ x0=spot_price, y0=0,
528
+ x1=spot_price, y1=1, # Will be scaled automatically
529
+ line=dict(color='red', width=2, dash='dash')
530
+ )
531
+
532
+ # Update layout
533
+ fig.update_layout(
534
+ title="Risk-Neutral Densities Across Expiries",
535
+ xaxis_title='Price',
536
+ yaxis_title='Density',
537
+ template='plotly_dark',
538
+ legend=dict(
539
+ yanchor="top",
540
+ y=0.99,
541
+ xanchor="left",
542
+ x=0.01
543
+ )
544
+ )
545
+
546
+ return fig
547
+
548
+
549
+ @catch_exception
550
+ def plot_rnd_3d(moneyness_grid: np.ndarray,
551
+ rnd_surface: Dict[str, np.ndarray],
552
+ param_matrix: pd.DataFrame,
553
+ spot_price: float = 1.0) -> go.Figure:
554
+ """
555
+ Plot 3D surface of risk-neutral densities.
556
+
557
+ Parameters:
558
+ - moneyness_grid: Grid of log-moneyness values
559
+ - rnd_surface: Dictionary mapping maturity names to RND arrays
560
+ - param_matrix: Matrix containing model parameters with maturity info
561
+ - spot_price: Spot price for converting to absolute prices
562
+
563
+ Returns:
564
+ - Plotly figure
565
+ """
566
+ # Get maturity information
567
+ dte_values = param_matrix.attrs['dte_values']
568
+
569
+ # Get maturity names in order by DTE
570
+ maturity_names = sorted(rnd_surface.keys(), key=lambda x: dte_values[x])
571
+
572
+ # Extract DTE values for z-axis
573
+ dte_list = [dte_values[name] for name in maturity_names]
574
+
575
+ # Convert to prices
576
+ prices = spot_price * np.exp(moneyness_grid)
577
+
578
+ # Create z-data matrix and normalize RNDs
579
+ z_data = np.zeros((len(maturity_names), len(prices)))
580
+
581
+ for i, name in enumerate(maturity_names):
582
+ rnd = rnd_surface[name]
583
+
584
+ # Normalize the RND
585
+ dx = moneyness_grid[1] - moneyness_grid[0]
586
+ total_density = np.sum(rnd) * dx
587
+ normalized_rnd = rnd / total_density if total_density > 0 else rnd
588
+
589
+ z_data[i] = normalized_rnd
590
+
591
+ # Create mesh grid
592
+ X, Y = np.meshgrid(prices, dte_list)
593
+
594
+ # Create 3D surface
595
+ fig = go.Figure(data=[
596
+ go.Surface(
597
+ z=z_data,
598
+ x=X,
599
+ y=Y,
600
+ colorscale='Viridis',
601
+ showscale=True
602
+ )
603
+ ])
604
+
605
+ # Update layout
606
+ fig.update_layout(
607
+ title="3D Risk-Neutral Density Surface",
608
+ scene=dict(
609
+ xaxis_title="Price",
610
+ yaxis_title="Days to Expiry",
611
+ zaxis_title="Density"
612
+ ),
613
+ margin=dict(l=65, r=50, b=65, t=90),
614
+ template="plotly_dark"
615
+ )
616
+
617
+ return fig
618
+
619
+
620
+ @catch_exception
621
+ def plot_rnd_statistics(rnd_statistics: pd.DataFrame,
622
+ rnd_probabilities: pd.DataFrame) -> Tuple[go.Figure, go.Figure]:
623
+ """
624
+ Plot RND statistics and probabilities.
625
+
626
+ Parameters:
627
+ - rnd_statistics: DataFrame with RND statistics
628
+ - rnd_probabilities: DataFrame with RND probabilities
629
+
630
+ Returns:
631
+ - Tuple of (statistics_fig, probabilities_fig)
632
+ """
633
+ # Create subplot figure for key statistics
634
+ stats_fig = make_subplots(
635
+ rows=1, cols=3,
636
+ subplot_titles=("Standard Deviation (%) vs. DTE",
637
+ "Skewness vs. DTE",
638
+ "Excess Kurtosis vs. DTE")
639
+ )
640
+
641
+ # Add traces for each statistic
642
+ stats_fig.add_trace(
643
+ go.Scatter(
644
+ x=rnd_statistics["dte"],
645
+ y=rnd_statistics["std_dev_pct"],
646
+ mode="lines+markers",
647
+ name="Standard Deviation (%)",
648
+ hovertemplate="DTE: %{x:.1f}<br>Std Dev: %{y:.2f}%"
649
+ ),
650
+ row=1, col=1
651
+ )
652
+
653
+ stats_fig.add_trace(
654
+ go.Scatter(
655
+ x=rnd_statistics["dte"],
656
+ y=rnd_statistics["skewness"],
657
+ mode="lines+markers",
658
+ name="Skewness",
659
+ hovertemplate="DTE: %{x:.1f}<br>Skewness: %{y:.4f}"
660
+ ),
661
+ row=1, col=2
662
+ )
663
+
664
+ stats_fig.add_trace(
665
+ go.Scatter(
666
+ x=rnd_statistics["dte"],
667
+ y=rnd_statistics["excess_kurtosis"],
668
+ mode="lines+markers",
669
+ name="Excess Kurtosis",
670
+ hovertemplate="DTE: %{x:.1f}<br>Excess Kurtosis: %{y:.4f}"
671
+ ),
672
+ row=1, col=3
673
+ )
674
+
675
+ # Update layout
676
+ stats_fig.update_layout(
677
+ title="Risk-Neutral Density Statistics Across Expiries",
678
+ template="plotly_dark",
679
+ height=500,
680
+ showlegend=False
681
+ )
682
+
683
+ # Update axes
684
+ stats_fig.update_xaxes(title_text="Days to Expiry", row=1, col=1)
685
+ stats_fig.update_xaxes(title_text="Days to Expiry", row=1, col=2)
686
+ stats_fig.update_xaxes(title_text="Days to Expiry", row=1, col=3)
687
+
688
+ stats_fig.update_yaxes(title_text="Standard Deviation (%)", row=1, col=1)
689
+ stats_fig.update_yaxes(title_text="Skewness", row=1, col=2)
690
+ stats_fig.update_yaxes(title_text="Excess Kurtosis", row=1, col=3)
691
+
692
+ # Create a second figure for probability thresholds
693
+ prob_fig = go.Figure()
694
+
695
+ # Get probability columns (those starting with "p_")
696
+ prob_cols = [col for col in rnd_probabilities.columns if col.startswith("p_")]
697
+
698
+ # Sort the columns to ensure they're in order by threshold value
699
+ prob_cols_above = sorted([col for col in prob_cols if "above" in col],
700
+ key=lambda x: float(x.split("_")[2]))
701
+ prob_cols_below = sorted([col for col in prob_cols if "below" in col],
702
+ key=lambda x: float(x.split("_")[2]))
703
+
704
+ # Color gradients
705
+ green_colors = [
706
+ 'rgba(144, 238, 144, 1)', # Light green
707
+ 'rgba(50, 205, 50, 1)', # Lime green
708
+ 'rgba(34, 139, 34, 1)', # Forest green
709
+ 'rgba(0, 100, 0, 1)' # Dark green
710
+ ]
711
+
712
+ red_colors = [
713
+ 'rgba(139, 0, 0, 1)', # Dark red
714
+ 'rgba(220, 20, 60, 1)', # Crimson
715
+ 'rgba(240, 128, 128, 1)', # Light coral
716
+ 'rgba(255, 182, 193, 1)' # Light pink/red
717
+ ]
718
+
719
+ # Add lines for upside probabilities (green)
720
+ for i, col in enumerate(prob_cols_above):
721
+ threshold = float(col.split("_")[2])
722
+ label = f"P(X > {threshold})"
723
+
724
+ # Select color based on how far OTM
725
+ color_idx = min(i, len(green_colors) - 1)
726
+
727
+ prob_fig.add_trace(
728
+ go.Scatter(
729
+ x=rnd_probabilities["dte"],
730
+ y=rnd_probabilities[col] * 100, # Convert to percentage
731
+ mode="lines+markers",
732
+ name=label,
733
+ line=dict(color=green_colors[color_idx], width=3),
734
+ marker=dict(size=8, color=green_colors[color_idx]),
735
+ hovertemplate="DTE: %{x:.1f}<br>" + label + ": %{y:.2f}%"
736
+ )
737
+ )
738
+
739
+ # Add lines for downside probabilities (red)
740
+ for i, col in enumerate(prob_cols_below):
741
+ threshold = float(col.split("_")[2])
742
+ label = f"P(X < {threshold})"
743
+
744
+ # Select color based on how far OTM
745
+ color_idx = min(i, len(red_colors) - 1)
746
+
747
+ prob_fig.add_trace(
748
+ go.Scatter(
749
+ x=rnd_probabilities["dte"],
750
+ y=rnd_probabilities[col] * 100, # Convert to percentage
751
+ mode="lines+markers",
752
+ name=label,
753
+ line=dict(color=red_colors[color_idx], width=3),
754
+ marker=dict(size=8, color=red_colors[color_idx]),
755
+ hovertemplate="DTE: %{x:.1f}<br>" + label + ": %{y:.2f}%"
756
+ )
757
+ )
758
+
759
+ # Update layout
760
+ prob_fig.update_layout(
761
+ title="Probability Thresholds Across Expiries",
762
+ xaxis_title="Days to Expiry",
763
+ yaxis_title="Probability (%)",
764
+ template="plotly_dark",
765
+ legend=dict(
766
+ yanchor="top",
767
+ y=0.99,
768
+ xanchor="right",
769
+ x=0.99
770
+ )
771
+ )
772
+
773
+ return stats_fig, prob_fig
774
+
775
+
776
+ @catch_exception
777
+ def plot_cdf(moneyness_grid: np.ndarray,
778
+ rnd_values: np.ndarray,
779
+ spot_price: float = 1.0,
780
+ title: str = 'Cumulative Distribution Function') -> go.Figure:
781
+ """
782
+ Plot the cumulative distribution function (CDF) from RND values.
783
+
784
+ Parameters:
785
+ - moneyness_grid: Grid of log-moneyness values
786
+ - rnd_values: RND values
787
+ - spot_price: Spot price for converting to absolute prices
788
+ - title: Plot title
789
+
790
+ Returns:
791
+ - Plotly figure
792
+ """
793
+ # Convert to prices and normalize RND
794
+ prices = spot_price * np.exp(moneyness_grid)
795
+
796
+ # Normalize the RND
797
+ dx = moneyness_grid[1] - moneyness_grid[0]
798
+ total_density = np.sum(rnd_values) * dx
799
+ normalized_rnd = rnd_values / total_density if total_density > 0 else rnd_values
800
+
801
+ # Calculate CDF
802
+ cdf = np.cumsum(normalized_rnd) * dx
803
+
804
+ # Create figure
805
+ fig = go.Figure()
806
+
807
+ # Add CDF trace
808
+ fig.add_trace(
809
+ go.Scatter(
810
+ x=prices,
811
+ y=cdf,
812
+ mode='lines',
813
+ name='CDF',
814
+ line=dict(color='#00FFC1', width=2)
815
+ )
816
+ )
817
+
818
+ # Add vertical line at spot price
819
+ fig.add_shape(
820
+ type='line',
821
+ x0=spot_price, y0=0,
822
+ x1=spot_price, y1=1,
823
+ line=dict(color='red', width=2, dash='dash')
824
+ )
825
+
826
+ # Add horizontal line at CDF=0.5 (median)
827
+ fig.add_shape(
828
+ type='line',
829
+ x0=prices[0], y0=0.5,
830
+ x1=prices[-1], y1=0.5,
831
+ line=dict(color='orange', width=2, dash='dash')
832
+ )
833
+
834
+ # Add annotation for spot price
835
+ fig.add_annotation(
836
+ x=spot_price,
837
+ y=1.05,
838
+ text=f"Spot: {spot_price}",
839
+ showarrow=False,
840
+ font=dict(color='red')
841
+ )
842
+
843
+ # Update layout
844
+ fig.update_layout(
845
+ title=title,
846
+ xaxis_title='Price',
847
+ yaxis_title='Cumulative Probability',
848
+ template='plotly_dark',
849
+ yaxis=dict(range=[0, 1.1]),
850
+ showlegend=False
851
+ )
852
+
853
+ return fig
854
+
855
+
856
+ @catch_exception
857
+ def plot_pdf(moneyness_grid: np.ndarray,
858
+ rnd_values: np.ndarray,
859
+ spot_price: float = 1.0,
860
+ title: str = 'Probability Density Function') -> go.Figure:
861
+ """
862
+ Plot the probability density function (PDF) from RND values.
863
+
864
+ Parameters:
865
+ - moneyness_grid: Grid of log-moneyness values
866
+ - rnd_values: RND values
867
+ - spot_price: Spot price for converting to absolute prices
868
+ - title: Plot title
869
+
870
+ Returns:
871
+ - Plotly figure
872
+ """
873
+ # This is essentially the same as plot_rnd but with a different title
874
+ return plot_rnd(moneyness_grid, rnd_values, spot_price, title)
875
+
876
+
877
+ @catch_exception
878
+ def plot_interpolated_surface(
879
+ interp_results: Dict[str, Any],
880
+ title: str = 'Interpolated Implied Volatility Surface'
881
+ ) -> go.Figure:
882
+ """
883
+ Plot interpolated implied volatility surface.
884
+
885
+ Parameters:
886
+ - interp_results: Dictionary with interpolation results
887
+ - title: Plot title
888
+
889
+ Returns:
890
+ - Plotly figure
891
+ """
892
+ # Extract data from interpolation results
893
+ moneyness_grid = interp_results['moneyness_grid']
894
+ target_expiries_years = interp_results['target_expiries_years']
895
+ iv_surface = interp_results['iv_surface']
896
+
897
+ # Create a 3D surface plot
898
+ fig = plot_3d_surface(
899
+ moneyness=moneyness_grid,
900
+ expiries=target_expiries_years,
901
+ iv_surface=iv_surface,
902
+ title=title
903
+ )
904
+
905
+ return fig
906
+
907
+
908
+ @catch_exception
909
+ def generate_all_plots(fit_results: Dict[str, Any],
910
+ rnd_results: Optional[Dict[str, Any]] = None,
911
+ market_data: Optional[pd.DataFrame] = None) -> Dict[str, go.Figure]:
912
+ """
913
+ Generate all plots for the fitted model and RND results.
914
+
915
+ Parameters:
916
+ - fit_results: Dictionary with fitting results from fit_model()
917
+ - rnd_results: Optional dictionary with RND results from calculate_rnd()
918
+ - market_data: Optional market data for comparison
919
+
920
+ Returns:
921
+ - Dictionary of plot figures
922
+ """
923
+ plots = {}
924
+
925
+ # Extract data from fit results
926
+ moneyness_grid = fit_results['moneyness_grid']
927
+ iv_surface = fit_results['iv_surface']
928
+ raw_param_matrix = fit_results['raw_param_matrix']
929
+ jw_param_matrix = fit_results.get('jw_param_matrix')
930
+ stats_df = fit_results.get('stats_df')
931
+ unique_expiries = fit_results['unique_expiries']
932
+
933
+ # Plot volatility smiles
934
+ logger.info("Generating volatility smile plots...")
935
+ plots['smiles'] = plot_all_smiles(moneyness_grid, iv_surface, market_data)
936
+
937
+ # Plot 3D surface
938
+ logger.info("Generating 3D volatility surface plot...")
939
+ plots['surface_3d'] = plot_3d_surface(moneyness_grid, unique_expiries, iv_surface)
940
+
941
+ # Plot parameters
942
+ logger.info("Generating parameter plots...")
943
+ plots['raw_params'], plots['jw_params'] = plot_parameters(raw_param_matrix, jw_param_matrix)
944
+
945
+ # Plot fit statistics if available
946
+ if stats_df is not None:
947
+ logger.info("Generating fit statistics plot...")
948
+ plots['fit_stats'] = plot_fit_statistics(stats_df)
949
+
950
+ # Plot RND results if available
951
+ if rnd_results is not None:
952
+ logger.info("Generating RND plots...")
953
+
954
+ # Extract RND data
955
+ rnd_surface = rnd_results['rnd_surface']
956
+ rnd_statistics = rnd_results['rnd_statistics']
957
+ rnd_probabilities = rnd_results['rnd_probabilities']
958
+ spot_price = rnd_results['spot_price']
959
+
960
+ # Plot RND for each expiry
961
+ plots['rnd'] = {}
962
+ for maturity_name, rnd_values in rnd_surface.items():
963
+ plots['rnd'][maturity_name] = plot_rnd(
964
+ moneyness_grid, rnd_values, spot_price,
965
+ title=f"Risk-Neutral Density - {maturity_name}"
966
+ )
967
+
968
+ # Plot all RNDs in one figure
969
+ plots['rnd_all'] = plot_rnd_all_expiries(moneyness_grid, rnd_surface, raw_param_matrix, spot_price)
970
+
971
+ # Plot 3D RND surface
972
+ plots['rnd_3d'] = plot_rnd_3d(moneyness_grid, rnd_surface, raw_param_matrix, spot_price)
973
+
974
+ # Plot RND statistics
975
+ plots['rnd_stats'], plots['rnd_probs'] = plot_rnd_statistics(rnd_statistics, rnd_probabilities)
976
+
977
+ # Plot PDF and CDF for the first expiry
978
+ first_maturity = list(rnd_surface.keys())[0]
979
+ first_rnd = rnd_surface[first_maturity]
980
+
981
+ plots['pdf'] = plot_pdf(moneyness_grid, first_rnd, spot_price)
982
+ plots['cdf'] = plot_cdf(moneyness_grid, first_rnd, spot_price)
983
+
984
+ return plots