squarequant 0.1.0__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.
@@ -0,0 +1,972 @@
1
+ """
2
+ Plotting functions for SquareSquant
3
+ """
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ import matplotlib.pyplot as plt
8
+ import matplotlib.gridspec as gridspec
9
+ from typing import List, Optional, Dict, Tuple, Union, Callable
10
+
11
+ from squarequant.constants import TRADING_DAYS_PER_YEAR
12
+
13
+ # Import the metrics API for reuse
14
+ from squarequant.api import (
15
+ sharpe, sortino, vol, mdd, var, calmar, cvar,
16
+ semidev, avgdd, ulcer, mad, erm, evar
17
+ )
18
+
19
+ # Metric function and label mappings - defined once at module level for reuse
20
+ METRIC_FUNCTIONS = {
21
+ 'sharpe': sharpe,
22
+ 'sortino': sortino,
23
+ 'vol': vol,
24
+ 'mdd': mdd,
25
+ 'var': var,
26
+ 'calmar': calmar,
27
+ 'cvar': cvar,
28
+ 'semidev': semidev,
29
+ 'avgdd': avgdd,
30
+ 'ulcer': ulcer,
31
+ 'mad': mad,
32
+ 'erm': erm,
33
+ 'evar': evar
34
+ }
35
+
36
+ METRIC_LABELS = {
37
+ 'sharpe': 'Sharpe Ratio',
38
+ 'sortino': 'Sortino Ratio',
39
+ 'vol': 'Volatility',
40
+ 'mdd': 'Maximum Drawdown',
41
+ 'var': 'Value at Risk',
42
+ 'calmar': 'Calmar Ratio',
43
+ 'cvar': 'Conditional Value at Risk',
44
+ 'semidev': 'Semi-Deviation',
45
+ 'avgdd': 'Average Drawdown',
46
+ 'ulcer': 'Ulcer Index',
47
+ 'mad': 'Mean Absolute Deviation',
48
+ 'erm': 'Entropic Risk Measure',
49
+ 'evar': 'Entropic Value at Risk'
50
+ }
51
+
52
+ # Define a date filtering function
53
+ def _filter_date_range(df: pd.DataFrame,
54
+ start: Optional[str] = None,
55
+ end: Optional[str] = None) -> pd.DataFrame:
56
+ """
57
+ Filter a DataFrame by date range.
58
+
59
+ Parameters:
60
+ df (DataFrame): DataFrame to filter
61
+ start (str, optional): Start date in format 'YYYY-MM-DD'
62
+ end (str, optional): End date in format 'YYYY-MM-DD'
63
+
64
+ Returns:
65
+ DataFrame: Filtered DataFrame
66
+ """
67
+ if not (start or end):
68
+ return df
69
+
70
+ mask = pd.Series(True, index=df.index)
71
+
72
+ if start:
73
+ mask &= (df.index >= start)
74
+
75
+ if end:
76
+ mask &= (df.index <= end)
77
+
78
+ return df[mask]
79
+
80
+
81
+ def _calculate_metrics(data: pd.DataFrame,
82
+ assets: List[str],
83
+ metrics: List[str],
84
+ windows: Dict[str, Optional[int]] = None,
85
+ risk_free: float = 0.0) -> Dict[str, pd.DataFrame]:
86
+ """
87
+ Calculate multiple metrics efficiently in one function.
88
+
89
+ Parameters:
90
+ data (DataFrame): DataFrame with asset price/returns data
91
+ assets (List[str]): Asset columns to analyze
92
+ metrics (List[str]): List of metrics to calculate
93
+ windows (Dict[str, int], optional): Dict mapping metric names to window sizes
94
+ risk_free (float): Risk-free rate for Sharpe and Sortino calculations
95
+
96
+ Returns:
97
+ Dict[str, DataFrame]: Dictionary mapping metric names to calculated DataFrames
98
+ """
99
+ if windows is None:
100
+ windows = {}
101
+
102
+ # Calculate all requested metrics in one pass
103
+ metric_data = {}
104
+ for metric in metrics:
105
+ if metric not in METRIC_FUNCTIONS:
106
+ continue
107
+
108
+ kwargs = {}
109
+ if metric in ['sharpe', 'sortino']:
110
+ kwargs['freerate_value'] = risk_free
111
+ if windows.get(metric) is not None:
112
+ kwargs['window'] = windows[metric]
113
+
114
+ # Calculate and store the metric
115
+ metric_data[metric] = METRIC_FUNCTIONS[metric](data, assets, **kwargs)
116
+
117
+ return metric_data
118
+
119
+
120
+ def plot_rolling_metrics(data: pd.DataFrame,
121
+ assets: List[str],
122
+ metrics: List[str] = ['sharpe', 'vol', 'mdd'],
123
+ windows: Optional[Dict[str, int]] = None,
124
+ cmap: str = 'viridis',
125
+ height: int = 10,
126
+ width: int = 12,
127
+ title: str = 'Rolling Risk Metrics',
128
+ ax: Optional[List[plt.Axes]] = None) -> List[plt.Axes]:
129
+ """
130
+ Plot multiple rolling risk metrics for selected assets using PyBull's metrics functions.
131
+
132
+ Parameters:
133
+ data (DataFrame): DataFrame with asset price data
134
+ assets (List[str]): Asset columns to plot
135
+ metrics (List[str]): List of metrics to plot - options: 'sharpe', 'sortino', 'vol', 'mdd',
136
+ 'var', 'calmar', 'cvar', 'semidev', 'avgdd', 'ulcer', 'mad'
137
+ windows (Dict[str, int], optional): Dict mapping metric names to window sizes
138
+ cmap (str): Colormap to use for different assets
139
+ height (int): Height of the plot in inches
140
+ width (int): Width of the plot in inches
141
+ title (str): Plot title
142
+ ax (List[plt.Axes], optional): List of Matplotlib axes to plot on
143
+
144
+ Returns:
145
+ List[plt.Axes]: List of Matplotlib axes objects with the plot
146
+ """
147
+ # Validate metrics
148
+ valid_metrics = [m for m in metrics if m in METRIC_FUNCTIONS]
149
+ if not valid_metrics:
150
+ raise ValueError(f"No valid metrics selected. Choose from: {list(METRIC_FUNCTIONS.keys())}")
151
+
152
+ # Set up windows dict if provided as list or None
153
+ if windows is None:
154
+ windows = {}
155
+
156
+ # Calculate all metrics at once
157
+ metric_data = _calculate_metrics(data, assets, valid_metrics, windows)
158
+
159
+ # Set up plots
160
+ if ax is None:
161
+ fig, ax = plt.subplots(len(valid_metrics), 1, figsize=(width, height),
162
+ sharex=True, constrained_layout=True)
163
+ if len(valid_metrics) == 1:
164
+ ax = [ax]
165
+
166
+ # Ensure ax is a list for consistent indexing
167
+ if not isinstance(ax, list):
168
+ ax = [ax]
169
+
170
+ # Get color map only once
171
+ colors = plt.cm.get_cmap(cmap, len(assets))
172
+
173
+ # Plot metrics
174
+ for i, metric_name in enumerate(valid_metrics):
175
+ metric_df = metric_data[metric_name]
176
+
177
+ for j, asset in enumerate(assets):
178
+ if asset in metric_df.columns:
179
+ ax[i].plot(metric_df.index, metric_df[asset], color=colors(j), label=asset)
180
+
181
+ ax[i].set_title(METRIC_LABELS[metric_name])
182
+ ax[i].grid(True, alpha=0.3)
183
+ ax[i].legend(loc='best')
184
+
185
+ plt.suptitle(title, fontsize=14)
186
+ plt.tight_layout()
187
+
188
+ return ax
189
+
190
+
191
+ def plot_correlation_matrix(returns: pd.DataFrame,
192
+ assets: Optional[List[str]] = None,
193
+ cmap: str = 'RdBu_r',
194
+ height: int = 8,
195
+ width: int = 10,
196
+ title: str = 'Correlation Matrix',
197
+ ax: Optional[plt.Axes] = None) -> plt.Axes:
198
+ """
199
+ Plot correlation matrix for selected assets.
200
+
201
+ Parameters:
202
+ returns (DataFrame): DataFrame with asset returns
203
+ assets (List[str], optional): Asset columns to include. If None, use all columns
204
+ cmap (str): Colormap for the correlation matrix
205
+ height (int): Height of the plot in inches
206
+ width (int): Width of the plot in inches
207
+ title (str): Plot title
208
+ ax (plt.Axes, optional): Matplotlib axes to plot on
209
+
210
+ Returns:
211
+ plt.Axes: Matplotlib axes object with the plot
212
+ """
213
+ if assets is None:
214
+ assets = returns.columns.tolist()
215
+
216
+ # Calculate correlation matrix only for the selected assets
217
+ corr = returns[assets].corr()
218
+
219
+ if ax is None:
220
+ fig, ax = plt.subplots(figsize=(width, height))
221
+
222
+ im = ax.imshow(corr.values, cmap=cmap, vmin=-1, vmax=1)
223
+
224
+ # Add colorbar
225
+ cbar = plt.colorbar(im, ax=ax)
226
+ cbar.set_label('Correlation Coefficient')
227
+
228
+ # Add labels and ticks
229
+ ax.set_xticks(np.arange(len(assets)))
230
+ ax.set_yticks(np.arange(len(assets)))
231
+ ax.set_xticklabels(assets, rotation=45, ha='right')
232
+ ax.set_yticklabels(assets)
233
+
234
+ # Use vectorized approach for text labels when possible
235
+ for i in range(len(assets)):
236
+ for j in range(len(assets)):
237
+ color = "white" if abs(corr.iloc[i, j]) >= 0.7 or i == j else "black"
238
+ text = "1.00" if i == j else f"{corr.iloc[i, j]:.2f}"
239
+ ax.text(j, i, text, ha="center", va="center", color=color)
240
+
241
+ ax.set_title(title)
242
+ plt.tight_layout()
243
+
244
+ return ax
245
+
246
+
247
+ def plot_drawdown(data: pd.DataFrame,
248
+ assets: List[str],
249
+ window: int = 181, # Use default from DEFAULT_DRAWDOWN_WINDOW
250
+ start: Optional[str] = None,
251
+ end: Optional[str] = None,
252
+ cmap: str = 'viridis',
253
+ height: int = 8,
254
+ width: int = 12,
255
+ title: str = 'Drawdown Analysis',
256
+ ax: Optional[List[plt.Axes]] = None) -> List[plt.Axes]:
257
+ """
258
+ Plot price series and drawdowns for selected assets using PyBull's mdd function.
259
+
260
+ Parameters:
261
+ data (DataFrame): DataFrame with asset price data
262
+ assets (List[str]): Asset columns to plot
263
+ window (int): Rolling window size in days for maximum drawdown calculation
264
+ start (str, optional): Start date in format 'YYYY-MM-DD'
265
+ end (str, optional): End date in format 'YYYY-MM-DD'
266
+ cmap (str): Colormap to use for different assets
267
+ height (int): Height of the plot in inches
268
+ width (int): Width of the plot in inches
269
+ title (str): Plot title
270
+ ax (List[plt.Axes], optional): List of two Matplotlib axes to plot on
271
+
272
+ Returns:
273
+ List[plt.Axes]: List of Matplotlib axes objects with the plots
274
+ """
275
+ # Calculate drawdowns directly
276
+ drawdown_data = mdd(data, assets, window=window, start=start, end=end)
277
+
278
+ # Filter price data by date range
279
+ filtered_data = _filter_date_range(data, start, end)
280
+
281
+ # Create figure and axes if not provided
282
+ if ax is None:
283
+ fig = plt.figure(figsize=(width, height))
284
+ gs = gridspec.GridSpec(2, 1, height_ratios=[1, 1])
285
+ ax = [plt.subplot(gs[0]), plt.subplot(gs[1])]
286
+
287
+ # Get colors once
288
+ colors = plt.cm.get_cmap(cmap, len(assets))
289
+
290
+ # Plot price series (normalized to 100)
291
+ first_prices = {asset: filtered_data[asset].iloc[0] for asset in assets if asset in filtered_data.columns}
292
+
293
+ for i, asset in enumerate(assets):
294
+ if asset in filtered_data.columns and first_prices[asset] != 0:
295
+ # Calculate normalized price efficiently using vectorized operations
296
+ normalized_price = filtered_data[asset] / first_prices[asset] * 100
297
+ ax[0].plot(filtered_data.index, normalized_price, color=colors(i), label=asset)
298
+
299
+ ax[0].set_title('Price Series (Normalized to 100)')
300
+ ax[0].grid(True, alpha=0.3)
301
+ ax[0].legend(loc='best')
302
+
303
+ # Plot drawdowns using calculated data
304
+ for i, asset in enumerate(assets):
305
+ if asset in drawdown_data.columns:
306
+ ax[1].plot(drawdown_data.index, drawdown_data[asset] * 100, color=colors(i), label=asset)
307
+
308
+ ax[1].set_title('Drawdowns (%)')
309
+ ax[1].grid(True, alpha=0.3)
310
+ ax[1].legend(loc='best')
311
+
312
+ plt.suptitle(title, fontsize=14)
313
+ plt.tight_layout()
314
+
315
+ return ax
316
+
317
+
318
+ def plot_risk_comparison(data: pd.DataFrame,
319
+ assets: List[str],
320
+ risk_metrics: List[str] = ['vol', 'mdd', 'var', 'cvar', 'semidev', 'ulcer', 'erm', 'evar'],
321
+ windows: Optional[Dict[str, int]] = None,
322
+ cmap: str = 'tab20',
323
+ height: int = 8,
324
+ width: int = 12,
325
+ title: str = 'Risk Metrics Comparison',
326
+ ax: Optional[plt.Axes] = None) -> plt.Axes:
327
+ """
328
+ Create a bar chart comparing risk metrics for different assets using PyBull metric functions.
329
+
330
+ Parameters:
331
+ data (DataFrame): DataFrame with asset price/returns data
332
+ assets (List[str]): Asset columns to compare
333
+ risk_metrics (List[str]): List of risk metrics to include
334
+ windows (Dict[str, int], optional): Dict mapping metric names to window sizes
335
+ cmap (str): Colormap to use for different metrics
336
+ height (int): Height of the plot in inches
337
+ width (int): Width of the plot in inches
338
+ title (str): Plot title
339
+ ax (plt.Axes, optional): Matplotlib axes to plot on
340
+
341
+ Returns:
342
+ plt.Axes: Matplotlib axes object with the plot
343
+ """
344
+ # Filter to only include valid risk metrics
345
+ valid_metrics = [m for m in risk_metrics if m in METRIC_FUNCTIONS]
346
+ if not valid_metrics:
347
+ raise ValueError(f"No valid risk metrics selected. Choose from: {list(METRIC_FUNCTIONS.keys())}")
348
+
349
+ # Set up default windows if not provided
350
+ if windows is None:
351
+ windows = {}
352
+
353
+ # Calculate all metrics at once
354
+ metric_data = _calculate_metrics(data, assets, valid_metrics, windows)
355
+
356
+ # Build comparison DataFrame
357
+ comparison_data = {}
358
+ for metric in valid_metrics:
359
+ metric_label = METRIC_LABELS[metric]
360
+ comparison_data[metric_label] = metric_data[metric].iloc[-1]
361
+
362
+ comparison_df = pd.DataFrame(comparison_data, index=assets)
363
+
364
+ if ax is None:
365
+ fig, ax = plt.subplots(figsize=(width, height))
366
+
367
+ # Plot the comparison
368
+ comparison_df.plot(kind='bar', ax=ax, cmap=cmap)
369
+
370
+ ax.set_title(title)
371
+ ax.set_ylabel('Metric Value')
372
+ ax.grid(True, alpha=0.3)
373
+ ax.legend(title='Risk Metrics')
374
+
375
+ plt.tight_layout()
376
+
377
+ return ax
378
+
379
+
380
+ def plot_performance_metrics(data: pd.DataFrame,
381
+ assets: List[str],
382
+ risk_free: float = 0.0,
383
+ cmap: str = 'viridis',
384
+ height: int = 8,
385
+ width: int = 10,
386
+ title: str = 'Performance Metrics',
387
+ ax: Optional[plt.Axes] = None) -> plt.Axes:
388
+ """
389
+ Create a table with key performance metrics using PyBull's metrics functions.
390
+
391
+ Parameters:
392
+ data (DataFrame): DataFrame with asset price/returns data
393
+ assets (List[str]): Asset columns to analyze
394
+ risk_free (float): Risk-free rate for Sharpe and Sortino calculations
395
+ cmap (str): Colormap for heatmap display
396
+ height (int): Height of the plot in inches
397
+ width (int): Width of the plot in inches
398
+ title (str): Plot title
399
+ ax (plt.Axes, optional): Matplotlib axes to plot on
400
+
401
+ Returns:
402
+ plt.Axes: Matplotlib axes object with the plot
403
+ """
404
+ if ax is None:
405
+ fig, ax = plt.subplots(figsize=(width, height))
406
+
407
+ # Calculate all metrics at once
408
+ metrics_to_calculate = ['sharpe', 'sortino', 'vol', 'mdd', 'calmar', 'var', 'cvar', 'semidev', 'ulcer']
409
+ metrics_dict = _calculate_metrics(data, assets, metrics_to_calculate, risk_free=risk_free)
410
+
411
+ # Get the latest value from each metric
412
+ metrics = pd.DataFrame(index=assets)
413
+
414
+ # Annualize returns - calculate only once
415
+ returns = data[assets].pct_change().dropna()
416
+ ann_returns = returns.mean() * TRADING_DAYS_PER_YEAR
417
+ metrics['Annualized Return'] = ann_returns
418
+
419
+ # Add metrics from calculation results
420
+ metrics['Volatility'] = metrics_dict['vol'].iloc[-1]
421
+ metrics['Maximum Drawdown'] = metrics_dict['mdd'].iloc[-1]
422
+ metrics['Sharpe Ratio'] = metrics_dict['sharpe'].iloc[-1]
423
+ metrics['Sortino Ratio'] = metrics_dict['sortino'].iloc[-1]
424
+ metrics['Calmar Ratio'] = metrics_dict['calmar'].iloc[-1]
425
+ metrics['Value at Risk (95%)'] = metrics_dict['var'].iloc[-1]
426
+ metrics['Conditional VaR (95%)'] = metrics_dict['cvar'].iloc[-1]
427
+ metrics['Semi-Deviation'] = metrics_dict['semidev'].iloc[-1]
428
+ metrics['Ulcer Index'] = metrics_dict['ulcer'].iloc[-1]
429
+
430
+ # Format percentages
431
+ percentage_cols = ['Annualized Return', 'Volatility', 'Maximum Drawdown',
432
+ 'Value at Risk (95%)', 'Conditional VaR (95%)', 'Semi-Deviation', 'Ulcer Index']
433
+
434
+ # Use vectorized operation instead of loop
435
+ metrics[percentage_cols] = metrics[percentage_cols] * 100
436
+
437
+ # Transpose for better display
438
+ metrics_t = metrics.T
439
+
440
+ # Display as a heatmap
441
+ cmap_obj = plt.get_cmap(cmap)
442
+
443
+ # Create a text-based table
444
+ ax.axis('tight')
445
+ ax.axis('off')
446
+
447
+ # For numerical formatting
448
+ def fmt(x):
449
+ if isinstance(x, (int, float)):
450
+ if abs(x) >= 10:
451
+ return f"{x:.2f}"
452
+ else:
453
+ return f"{x:.3f}"
454
+ return str(x)
455
+
456
+ # Create formatted cell text array once
457
+ cell_text = [[fmt(x) for x in row] for row in metrics_t.values]
458
+
459
+ # Create the table
460
+ table = ax.table(
461
+ cellText=cell_text,
462
+ rowLabels=metrics_t.index,
463
+ colLabels=metrics_t.columns,
464
+ cellLoc='center',
465
+ loc='center',
466
+ bbox=[0, 0, 1, 1]
467
+ )
468
+
469
+ # Style the table
470
+ table.auto_set_font_size(False)
471
+ table.set_fontsize(10)
472
+ table.scale(1, 1.5)
473
+
474
+ # Color cells based on values
475
+ # Use predefined categories to reduce computation
476
+ ratio_rows = [i for i, label in enumerate(metrics_t.index) if 'Ratio' in label]
477
+ risk_rows = [i for i, label in enumerate(metrics_t.index)
478
+ if any(x in label for x in ['Drawdown', 'Risk', 'Volatility', 'VaR', 'Semi-Deviation', 'Ulcer'])]
479
+ return_rows = [i for i, label in enumerate(metrics_t.index) if 'Return' in label]
480
+
481
+ # Color cells in batches by category
482
+ for i in ratio_rows:
483
+ row_vals = metrics_t.iloc[i].values
484
+ if min(row_vals) != max(row_vals):
485
+ min_val, max_val = min(row_vals), max(row_vals)
486
+ norm = plt.Normalize(min_val, max_val)
487
+ for j, val in enumerate(row_vals):
488
+ color = cmap_obj(norm(val))
489
+ table[(i+1, j)].set_facecolor(color)
490
+
491
+ for i in risk_rows:
492
+ row_vals = metrics_t.iloc[i].values
493
+ if min(row_vals) != max(row_vals):
494
+ min_val, max_val = min(row_vals), max(row_vals)
495
+ norm = plt.Normalize(min_val, max_val)
496
+ for j, val in enumerate(row_vals):
497
+ color = cmap_obj(1 - norm(val))
498
+ table[(i+1, j)].set_facecolor(color)
499
+
500
+ for i in return_rows:
501
+ row_vals = metrics_t.iloc[i].values
502
+ if min(row_vals) != max(row_vals):
503
+ min_val, max_val = min(row_vals), max(row_vals)
504
+ norm = plt.Normalize(min_val, max_val)
505
+ for j, val in enumerate(row_vals):
506
+ color = cmap_obj(norm(val))
507
+ table[(i+1, j)].set_facecolor(color)
508
+
509
+ ax.set_title(title, fontsize=14, pad=20)
510
+
511
+ return ax
512
+
513
+
514
+ def plot_risk_contribution(data: pd.DataFrame,
515
+ weights: Union[pd.Series, pd.DataFrame],
516
+ risk_measure: str = 'vol',
517
+ window: Optional[int] = None,
518
+ kind: str = 'bar',
519
+ height: int = 6,
520
+ width: int = 10,
521
+ title: str = 'Risk Contribution Analysis',
522
+ ax: Optional[plt.Axes] = None) -> plt.Axes:
523
+ """
524
+ Create a chart showing the risk contribution per asset in the portfolio
525
+ using PyBull's risk metrics.
526
+
527
+ Parameters:
528
+ data (DataFrame): DataFrame with asset returns/prices
529
+ weights (Series or DataFrame): Portfolio weights
530
+ risk_measure (str): Risk measure to use - 'vol', 'mdd', 'semidev', 'var', 'cvar', or 'ulcer'
531
+ window (int, optional): Window size for risk calculation
532
+ kind (str): Type of plot ('bar' or 'pie')
533
+ height (int): Height of the plot in inches
534
+ width (int): Width of the plot in inches
535
+ title (str): Plot title
536
+ ax (plt.Axes, optional): Matplotlib axes to plot on
537
+
538
+ Returns:
539
+ plt.Axes: Matplotlib axes object with the plot
540
+ """
541
+ # Ensure weights is a Series
542
+ if isinstance(weights, pd.DataFrame):
543
+ weights = weights.iloc[:, 0]
544
+
545
+ # Get asset list
546
+ assets = weights.index.tolist()
547
+
548
+ # Validate risk measure
549
+ risk_functions = {k: v for k, v in METRIC_FUNCTIONS.items() if k in ['vol', 'mdd', 'var', 'cvar', 'semidev', 'ulcer']}
550
+
551
+ if risk_measure not in risk_functions:
552
+ raise ValueError(f"Invalid risk measure: {risk_measure}. Choose from: {list(risk_functions.keys())}")
553
+
554
+ # Calculate risk metric using PyBull function
555
+ kwargs = {'window': window} if window is not None else {}
556
+ risk_data = risk_functions[risk_measure](data, assets, **kwargs)
557
+
558
+ # Calculate portfolio risk
559
+ portfolio_risk = 0.0
560
+
561
+ # Use efficient calculation method for each risk measure
562
+ if risk_measure == 'vol':
563
+ # For volatility, use matrix multiplication with covariance
564
+ returns = data[assets].pct_change().dropna()
565
+ risk_matrix = returns.cov() * np.sqrt(TRADING_DAYS_PER_YEAR)
566
+ portfolio_risk = np.sqrt(weights.dot(risk_matrix).dot(weights))
567
+ # Marginal contribution
568
+ marginal_contribution = risk_matrix.dot(weights) / (portfolio_risk if portfolio_risk != 0 else 1.0)
569
+ else:
570
+ # For other metrics, use approximation with latest risk values
571
+ latest_risk = risk_data.iloc[-1]
572
+ portfolio_risk = (weights * latest_risk).sum()
573
+ # Avoid division by zero
574
+ divisor = portfolio_risk if portfolio_risk != 0 else 1.0
575
+ marginal_contribution = latest_risk / divisor
576
+
577
+ # Calculate risk contribution
578
+ risk_contribution = weights * marginal_contribution
579
+
580
+ # Normalize to sum to 1 (vectorized)
581
+ risk_contribution_sum = risk_contribution.sum()
582
+ if risk_contribution_sum != 0:
583
+ risk_contribution = risk_contribution / risk_contribution_sum
584
+
585
+ # Sort by contribution for better visualization
586
+ risk_contribution = risk_contribution.sort_values(ascending=False)
587
+
588
+ # Create plot
589
+ if ax is None:
590
+ fig, ax = plt.subplots(figsize=(width, height))
591
+
592
+ if kind == 'bar':
593
+ # Plot bar chart
594
+ risk_contribution.plot(kind='bar', ax=ax, color='skyblue')
595
+
596
+ # Add equal contribution line
597
+ equal_contribution = 1.0 / len(risk_contribution)
598
+ ax.axhline(y=equal_contribution, color='red', linestyle='--',
599
+ label=f'Equal Contribution ({(100*equal_contribution):.1f}%)')
600
+
601
+ # Add percentage labels efficiently
602
+ for i, v in enumerate(risk_contribution):
603
+ ax.text(i, v + 0.01, f"{v*100:.1f}%", ha='center')
604
+
605
+ ax.set_ylabel('Risk Contribution (%)')
606
+ ax.set_title(f"{title}\n{risk_measure.upper()} Risk: {portfolio_risk*100:.2f}%")
607
+ ax.grid(True, alpha=0.3)
608
+ ax.legend()
609
+
610
+ elif kind == 'pie':
611
+ # Plot pie chart
612
+ risk_contribution.plot(kind='pie', ax=ax, autopct='%1.1f%%')
613
+ ax.set_ylabel('')
614
+ ax.set_title(f"{title}\n{risk_measure.upper()} Risk: {portfolio_risk*100:.2f}%")
615
+
616
+ plt.tight_layout()
617
+
618
+ return ax
619
+
620
+
621
+ def plot_returns_distribution(returns: pd.DataFrame,
622
+ assets: List[str],
623
+ cmap: str = 'tab20',
624
+ bins: int = 50,
625
+ kde: bool = True,
626
+ var_alpha: float = 0.05,
627
+ height: int = 6,
628
+ width: int = 10,
629
+ title: str = 'Returns Distribution Analysis',
630
+ ax: Optional[List[plt.Axes]] = None) -> List[plt.Axes]:
631
+ """
632
+ Plot the distribution of returns with key risk metrics using PyBull functions.
633
+
634
+ Parameters:
635
+ returns (DataFrame): DataFrame with asset returns
636
+ assets (List[str]): Asset columns to plot
637
+ cmap (str): Colormap to use for different assets
638
+ bins (int): Number of histogram bins
639
+ kde (bool): Whether to plot kernel density estimate
640
+ var_alpha (float): Significance level for Value at Risk
641
+ height (int): Height of the plot in inches
642
+ width (int): Width of the plot in inches
643
+ title (str): Plot title
644
+ ax (List[plt.Axes], optional): List of Matplotlib axes to plot on
645
+
646
+ Returns:
647
+ List[plt.Axes]: List of Matplotlib axes objects with the plots
648
+ """
649
+ # Calculate VaR and CVaR at once
650
+ metrics = _calculate_metrics(returns, assets, ['var', 'cvar'], {'var': None, 'cvar': None},
651
+ var_alpha=var_alpha, cvar_alpha=var_alpha)
652
+
653
+ var_data = metrics['var']
654
+ cvar_data = metrics['cvar']
655
+
656
+ # Create figure and axes if not provided
657
+ n_assets = len(assets)
658
+ if ax is None:
659
+ fig, ax = plt.subplots(n_assets, 1, figsize=(width, height * n_assets / 2),
660
+ sharex=True, constrained_layout=True)
661
+ if n_assets == 1:
662
+ ax = [ax]
663
+
664
+ # Get colors once
665
+ colors = plt.cm.get_cmap(cmap, n_assets)
666
+
667
+ # Precompute statistics for each asset
668
+ stats = {}
669
+ for asset in assets:
670
+ if asset in returns.columns:
671
+ asset_returns = returns[asset].dropna()
672
+ stats[asset] = {
673
+ 'mean': asset_returns.mean(),
674
+ 'std_dev': asset_returns.std(),
675
+ 'skew': asset_returns.skew(),
676
+ 'kurtosis': asset_returns.kurtosis(),
677
+ 'var': var_data[asset].iloc[-1],
678
+ 'cvar': cvar_data[asset].iloc[-1]
679
+ }
680
+
681
+ # Plot distributions
682
+ for i, asset in enumerate(assets):
683
+ if asset not in stats:
684
+ continue
685
+
686
+ asset_stats = stats[asset]
687
+ asset_returns = returns[asset].dropna()
688
+
689
+ # Plot histogram with KDE
690
+ ax[i].hist(asset_returns, bins=bins, alpha=0.5, color=colors(i),
691
+ density=True, label=f'{asset} Returns')
692
+
693
+ if kde:
694
+ asset_returns.plot.kde(ax=ax[i], color=colors(i), linewidth=2)
695
+
696
+ # Plot VaR and CVaR lines
697
+ ax[i].axvline(asset_stats['var'], color='red', linestyle='--', alpha=0.8,
698
+ label=f'VaR ({var_alpha*100}%): {asset_stats["var"]:.2%}')
699
+ ax[i].axvline(asset_stats['cvar'], color='darkred', linestyle='--', alpha=0.8,
700
+ label=f'CVaR ({var_alpha*100}%): {asset_stats["cvar"]:.2%}')
701
+ ax[i].axvline(asset_stats['mean'], color='black', linestyle='-', alpha=0.8,
702
+ label=f'Mean: {asset_stats["mean"]:.2%}')
703
+
704
+ # Create stats text once
705
+ stats_text = (f'Mean: {asset_stats["mean"]:.2%}\n'
706
+ f'Std Dev: {asset_stats["std_dev"]:.2%}\n'
707
+ f'Skewness: {asset_stats["skew"]:.2f}\n'
708
+ f'Kurtosis: {asset_stats["kurtosis"]:.2f}\n'
709
+ f'VaR ({var_alpha*100}%): {asset_stats["var"]:.2%}\n'
710
+ f'CVaR ({var_alpha*100}%): {asset_stats["cvar"]:.2%}')
711
+
712
+ # Position text at top right
713
+ ax[i].text(0.95, 0.95, stats_text, transform=ax[i].transAxes,
714
+ verticalalignment='top', horizontalalignment='right',
715
+ bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
716
+
717
+ ax[i].set_title(f'{asset} Return Distribution')
718
+ ax[i].grid(True, alpha=0.3)
719
+ ax[i].legend(loc='upper left')
720
+ ax[i].set_xlabel('Return')
721
+ ax[i].set_ylabel('Frequency')
722
+
723
+ plt.suptitle(title, fontsize=14)
724
+ plt.tight_layout()
725
+
726
+ return ax
727
+
728
+
729
+ def plot_multiple_drawdowns(data: pd.DataFrame,
730
+ assets: List[str],
731
+ plot_type: str = 'both',
732
+ cmap: str = 'tab10',
733
+ height: int = 10,
734
+ width: int = 12,
735
+ title: str = 'Drawdown Comparison',
736
+ ax: Optional[List[plt.Axes]] = None) -> List[plt.Axes]:
737
+ """
738
+ Plot multiple drawdown metrics for comparison using PyBull functions.
739
+
740
+ Parameters:
741
+ data (DataFrame): DataFrame with asset price data
742
+ assets (List[str]): Asset columns to plot
743
+ plot_type (str): Type of comparison: 'mdd', 'avgdd', 'ulcer', or 'both'
744
+ cmap (str): Colormap to use for different assets
745
+ height (int): Height of the plot in inches
746
+ width (int): Width of the plot in inches
747
+ title (str): Plot title
748
+ ax (List[plt.Axes], optional): List of Matplotlib axes to plot on
749
+
750
+ Returns:
751
+ List[plt.Axes]: List of Matplotlib axes objects with the plots
752
+ """
753
+ # Determine the metrics to calculate based on plot_type
754
+ if plot_type == 'both':
755
+ metrics_to_calc = ['mdd', 'avgdd', 'ulcer']
756
+ elif plot_type in ['mdd', 'avgdd', 'ulcer']:
757
+ metrics_to_calc = [plot_type]
758
+ else:
759
+ raise ValueError("plot_type must be 'mdd', 'avgdd', 'ulcer', or 'both'")
760
+
761
+ # Calculate all metrics at once
762
+ metric_data = _calculate_metrics(data, assets, metrics_to_calc)
763
+
764
+ # Set up plot
765
+ n_plots = len(metrics_to_calc)
766
+ if ax is None:
767
+ fig, ax = plt.subplots(n_plots, 1, figsize=(width, height),
768
+ sharex=True, constrained_layout=True)
769
+ if n_plots == 1:
770
+ ax = [ax]
771
+
772
+ # Ensure ax is a list
773
+ if not isinstance(ax, list):
774
+ ax = [ax]
775
+
776
+ # Get colors once
777
+ colors = plt.cm.get_cmap(cmap, len(assets))
778
+
779
+ # Define titles once
780
+ titles = {
781
+ 'mdd': 'Maximum Drawdown (%)',
782
+ 'avgdd': 'Average Drawdown (%)',
783
+ 'ulcer': 'Ulcer Index (%)'
784
+ }
785
+
786
+ # Plot metrics
787
+ for plot_index, metric in enumerate(metrics_to_calc):
788
+ current_data = metric_data[metric]
789
+
790
+ for i, asset in enumerate(assets):
791
+ if asset in current_data.columns:
792
+ ax[plot_index].plot(current_data.index, current_data[asset] * 100,
793
+ color=colors(i), label=asset)
794
+
795
+ ax[plot_index].set_title(titles[metric])
796
+ ax[plot_index].grid(True, alpha=0.3)
797
+ ax[plot_index].legend(loc='best')
798
+
799
+ plt.suptitle(title, fontsize=14)
800
+ plt.tight_layout()
801
+
802
+ return ax
803
+
804
+
805
+ def plot_volatility_comparison(data: pd.DataFrame,
806
+ assets: List[str],
807
+ plot_type: str = 'both',
808
+ cmap: str = 'tab10',
809
+ height: int = 10,
810
+ width: int = 12,
811
+ title: str = 'Volatility Metrics Comparison',
812
+ ax: Optional[List[plt.Axes]] = None) -> List[plt.Axes]:
813
+ """
814
+ Plot volatility and semi-deviation for comparison using PyBull functions.
815
+
816
+ Parameters:
817
+ data (DataFrame): DataFrame with asset price data
818
+ assets (List[str]): Asset columns to plot
819
+ plot_type (str): Type of comparison: 'vol', 'semidev', 'mad', or 'both'
820
+ cmap (str): Colormap to use for different assets
821
+ height (int): Height of the plot in inches
822
+ width (int): Width of the plot in inches
823
+ title (str): Plot title
824
+ ax (List[plt.Axes], optional): List of Matplotlib axes to plot on
825
+
826
+ Returns:
827
+ List[plt.Axes]: List of Matplotlib axes objects with the plots
828
+ """
829
+ # Determine the metrics to calculate based on plot_type
830
+ if plot_type == 'both':
831
+ metrics_to_calc = ['vol', 'semidev', 'mad']
832
+ elif plot_type in ['vol', 'semidev', 'mad']:
833
+ metrics_to_calc = [plot_type]
834
+ else:
835
+ raise ValueError("plot_type must be 'vol', 'semidev', 'mad', or 'both'")
836
+
837
+ # Calculate all metrics at once
838
+ metric_data = _calculate_metrics(data, assets, metrics_to_calc)
839
+
840
+ # Set up plot
841
+ n_plots = len(metrics_to_calc)
842
+ if ax is None:
843
+ fig, ax = plt.subplots(n_plots, 1, figsize=(width, height),
844
+ sharex=True, constrained_layout=True)
845
+ if n_plots == 1:
846
+ ax = [ax]
847
+
848
+ # Ensure ax is a list
849
+ if not isinstance(ax, list):
850
+ ax = [ax]
851
+
852
+ # Get colors once
853
+ colors = plt.cm.get_cmap(cmap, len(assets))
854
+
855
+ # Define titles once
856
+ titles = {
857
+ 'vol': 'Volatility (%)',
858
+ 'semidev': 'Semi-Deviation (%)',
859
+ 'mad': 'Mean Absolute Deviation (%)'
860
+ }
861
+
862
+ # Plot metrics
863
+ for plot_index, metric in enumerate(metrics_to_calc):
864
+ current_data = metric_data[metric]
865
+
866
+ for i, asset in enumerate(assets):
867
+ if asset in current_data.columns:
868
+ ax[plot_index].plot(current_data.index, current_data[asset] * 100,
869
+ color=colors(i), label=asset)
870
+
871
+ ax[plot_index].set_title(titles[metric])
872
+ ax[plot_index].grid(True, alpha=0.3)
873
+ ax[plot_index].legend(loc='best')
874
+
875
+ plt.suptitle(title, fontsize=14)
876
+ plt.tight_layout()
877
+
878
+ return ax
879
+
880
+
881
+ def plot_weight_allocation(weights: Union[pd.DataFrame, pd.Series],
882
+ kind: str = 'bar',
883
+ cmap: str = 'tab20',
884
+ height: int = 6,
885
+ width: int = 10,
886
+ title: str = 'Portfolio Weight Allocation',
887
+ threshold: float = 0.03,
888
+ ax: Optional[plt.Axes] = None) -> plt.Axes:
889
+ """
890
+ Create a chart visualizing portfolio weight allocation.
891
+
892
+ Parameters:
893
+ weights (DataFrame or Series): DataFrame with portfolio weights
894
+ kind (str): Type of plot ('bar', 'pie')
895
+ cmap (str): Colormap to use for assets
896
+ height (int): Height of the plot in inches
897
+ width (int): Width of the plot in inches
898
+ title (str): Plot title
899
+ threshold (float): Minimum weight to show as separate slice (for pie)
900
+ ax (plt.Axes, optional): Matplotlib axes to plot on
901
+
902
+ Returns:
903
+ plt.Axes: Matplotlib axes object with the plot
904
+ """
905
+ if ax is None:
906
+ fig, ax = plt.subplots(figsize=(width, height))
907
+
908
+ # Ensure weights is a Series
909
+ if isinstance(weights, pd.DataFrame) and weights.shape[1] == 1:
910
+ weights = weights.iloc[:, 0]
911
+
912
+ # Sort weights for better visualization
913
+ weights = weights.sort_values(ascending=False)
914
+
915
+ if kind == 'bar':
916
+ # Create colors map once
917
+ colors = [plt.cm.get_cmap(cmap)(i) for i in range(len(weights))]
918
+
919
+ # Plot horizontal bar chart
920
+ weights.plot(kind='barh', ax=ax, color=colors)
921
+
922
+ # Add percentage labels - use vectorized where possible
923
+ for i, v in enumerate(weights):
924
+ text_color = 'white' if v < 0 else 'black'
925
+ offset = -0.04 if v < 0 else 0.01
926
+ ax.text(v + offset, i, f"{v * 100:.1f}%", va='center', color=text_color)
927
+
928
+ ax.set_title(title)
929
+ ax.set_xlabel('Weight (%)')
930
+ ax.grid(True, alpha=0.3)
931
+
932
+ elif kind == 'pie':
933
+ # For pie chart, group small weights as "Others"
934
+ if any(weights < threshold):
935
+ # Efficiently process weight grouping
936
+ small_weights = weights[weights < threshold]
937
+ big_weights = weights[weights >= threshold]
938
+
939
+ if not small_weights.empty:
940
+ others_sum = small_weights.sum()
941
+ if others_sum > 0:
942
+ # Use pd.concat for combining Series
943
+ pie_data = pd.concat([big_weights,
944
+ pd.Series([others_sum], index=['Others'])])
945
+ else:
946
+ pie_data = big_weights
947
+ else:
948
+ pie_data = weights
949
+ else:
950
+ pie_data = weights
951
+
952
+ # Calculate colors once
953
+ colors = plt.cm.get_cmap(cmap, len(pie_data))
954
+
955
+ # Plot the pie chart
956
+ wedges, texts, autotexts = ax.pie(
957
+ pie_data,
958
+ labels=pie_data.index,
959
+ autopct='%1.1f%%',
960
+ colors=[colors(i) for i in range(len(pie_data))],
961
+ startangle=90
962
+ )
963
+
964
+ # Style labels efficiently
965
+ plt.setp(autotexts, size=9, weight="bold")
966
+
967
+ ax.set_ylabel('')
968
+ ax.set_title(title)
969
+
970
+ plt.tight_layout()
971
+
972
+ return ax