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.
- squarequant/__init__.py +41 -0
- squarequant/api.py +548 -0
- squarequant/constants.py +35 -0
- squarequant/core/__init__.py +6 -0
- squarequant/core/base.py +166 -0
- squarequant/core/data.py +106 -0
- squarequant/core/metrics.py +1633 -0
- squarequant/plot/__init__.py +7 -0
- squarequant/plot/plot.py +972 -0
- squarequant/utils/__init__.py +6 -0
- squarequant/utils/helpers.py +85 -0
- squarequant-0.1.0.dist-info/LICENSE +21 -0
- squarequant-0.1.0.dist-info/METADATA +217 -0
- squarequant-0.1.0.dist-info/RECORD +16 -0
- squarequant-0.1.0.dist-info/WHEEL +5 -0
- squarequant-0.1.0.dist-info/top_level.txt +1 -0
squarequant/plot/plot.py
ADDED
|
@@ -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
|