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,1633 @@
1
+ """
2
+ Risk metric implementations for the SquareQuant package
3
+ """
4
+
5
+ import pandas as pd
6
+ import numpy as np
7
+ from scipy import stats
8
+ from typing import List, Optional, Union
9
+
10
+ from squarequant.constants import (
11
+ TRADING_DAYS_PER_YEAR,
12
+ DEFAULT_SHARPE_WINDOW,
13
+ DEFAULT_SORTINO_WINDOW,
14
+ DEFAULT_VOLATILITY_WINDOW,
15
+ DEFAULT_DRAWDOWN_WINDOW,
16
+ DEFAULT_VAR_WINDOW,
17
+ DEFAULT_CALMAR_WINDOW,
18
+ DEFAULT_CVAR_WINDOW,
19
+ DEFAULT_CONFIDENCE,
20
+ DEFAULT_SEMIDEVIATION_WINDOW,
21
+ DEFAULT_AVGDRAWDOWN_WINDOW,
22
+ DEFAULT_ULCER_WINDOW,
23
+ DEFAULT_MAD_WINDOW,
24
+ DEFAULT_CDAR_WINDOW,
25
+ DEFAULT_EDAR_WINDOW,
26
+ VALID_VAR_METHODS
27
+ )
28
+
29
+ from squarequant.core.base import RiskMetricBase, RiskFreeRateHelper
30
+
31
+
32
+ class SharpeRatio(RiskMetricBase):
33
+ """
34
+ Calculate rolling Sharpe ratios for specified assets
35
+ """
36
+
37
+ def __init__(self,
38
+ data: pd.DataFrame,
39
+ assets: List[str],
40
+ freerate: Optional[str] = None,
41
+ freerate_value: Optional[float] = None,
42
+ window: int = DEFAULT_SHARPE_WINDOW,
43
+ start: Optional[str] = None,
44
+ end: Optional[str] = None):
45
+ """
46
+ Initialize Sharpe ratio calculator.
47
+
48
+ Parameters:
49
+ data (DataFrame): DataFrame with asset price data
50
+ assets (List[str]): List of asset columns to calculate for
51
+ freerate (str, optional): Column name for risk-free rate in data DataFrame
52
+ freerate_value (float, optional): Constant risk-free rate to use if no column provided
53
+ window (int): Rolling window size in trading days
54
+ start (str, optional): Start date in format 'YYYY-MM-DD'
55
+ end (str, optional): End date in format 'YYYY-MM-DD'
56
+ """
57
+ super().__init__(
58
+ data=data,
59
+ assets=assets,
60
+ window=window,
61
+ start=start,
62
+ end=end,
63
+ use_returns=True,
64
+ freerate=freerate,
65
+ freerate_value=freerate_value
66
+ )
67
+
68
+ # Get daily risk-free rate (without rolling average)
69
+ self.daily_risk_free_rate = RiskFreeRateHelper.get_risk_free_rate(
70
+ returns=self.data,
71
+ freerate=freerate,
72
+ freerate_value=freerate_value
73
+ )
74
+
75
+ def calculate(self) -> pd.DataFrame:
76
+ """
77
+ Calculate Sharpe ratio for all valid assets using a NumPy-optimized vectorized approach.
78
+
79
+ Returns:
80
+ DataFrame: Sharpe ratios for specified assets
81
+ """
82
+ if not self.valid_assets:
83
+ return self.result
84
+
85
+ # Calculate daily excess returns (in-place where possible)
86
+ # Use self.result to store intermediate calculations
87
+ self.result[self.valid_assets] = self.data[self.valid_assets].sub(self.daily_risk_free_rate, axis=0)
88
+
89
+ # Calculate rolling statistics directly into result
90
+ rolling_mean = self.result[self.valid_assets].rolling(window=self.window, min_periods=self.min_periods).mean()
91
+
92
+ # Store std temporarily in result
93
+ self.result[self.valid_assets] = self.result[self.valid_assets].rolling(window=self.window,
94
+ min_periods=self.min_periods).std(
95
+ ddof=1)
96
+
97
+ # Replace zero standard deviations with NaN to avoid division by zero (in-place)
98
+ # Calculate final Sharpe ratio
99
+ self.result[self.valid_assets] = rolling_mean.div(
100
+ self.result[self.valid_assets].replace(0, np.nan)
101
+ ) * np.sqrt(TRADING_DAYS_PER_YEAR)
102
+
103
+ return self._finalize_result(self.result[self.valid_assets])
104
+
105
+ class SortinoRatio(RiskMetricBase):
106
+ """
107
+ Calculate rolling Sortino ratios for specified assets
108
+ """
109
+
110
+ def __init__(self,
111
+ data: pd.DataFrame,
112
+ assets: List[str],
113
+ freerate: Optional[str] = None,
114
+ freerate_value: Optional[float] = None,
115
+ window: int = DEFAULT_SORTINO_WINDOW,
116
+ start: Optional[str] = None,
117
+ end: Optional[str] = None):
118
+ """
119
+ Initialize Sortino ratio calculator.
120
+
121
+ Parameters:
122
+ data (DataFrame): DataFrame with asset price data
123
+ assets (List[str]): List of asset columns to calculate for
124
+ freerate (str, optional): Column name for risk-free rate in data DataFrame
125
+ freerate_value (float, optional): Constant risk-free rate to use if no column provided
126
+ window (int): Rolling window size in trading days
127
+ start (str, optional): Start date in format 'YYYY-MM-DD'
128
+ end (str, optional): End date in format 'YYYY-MM-DD'
129
+ """
130
+ super().__init__(
131
+ data=data,
132
+ assets=assets,
133
+ window=window,
134
+ start=start,
135
+ end=end,
136
+ use_returns=True,
137
+ freerate=freerate,
138
+ freerate_value=freerate_value
139
+ )
140
+
141
+ # Get daily risk-free rate (without rolling average)
142
+ self.daily_risk_free_rate = RiskFreeRateHelper.get_risk_free_rate(
143
+ returns=self.data,
144
+ freerate=freerate,
145
+ freerate_value=freerate_value
146
+ )
147
+
148
+ def calculate(self) -> pd.DataFrame:
149
+ """
150
+ Calculate Sortino ratio for all valid assets using vectorized operations.
151
+
152
+ Returns:
153
+ DataFrame: Sortino ratios for specified assets
154
+ """
155
+ if not self.valid_assets:
156
+ return self.result
157
+
158
+ # Use result DataFrame for intermediate calculations
159
+ # Calculate daily excess returns directly into result
160
+ self.result[self.valid_assets] = self.data[self.valid_assets].sub(self.daily_risk_free_rate, axis=0)
161
+
162
+ # Store rolling mean
163
+ rolling_mean = self.result[self.valid_assets].rolling(window=self.window, min_periods=self.min_periods).mean()
164
+
165
+ # Create a custom function to calculate downside deviation for a pandas Series
166
+ def downside_deviation(series):
167
+ # Only consider negative returns for downside risk
168
+ downside_returns = np.minimum(series, 0)
169
+ # Compute root mean square of negative returns
170
+ return np.sqrt(np.mean(np.square(downside_returns))) if len(series) > 0 else np.nan
171
+
172
+ # Calculate downside deviation directly into result
173
+ for asset in self.valid_assets:
174
+ self.result[asset] = self.result[asset].rolling(
175
+ window=self.window,
176
+ min_periods=self.min_periods
177
+ ).apply(downside_deviation, raw=True)
178
+
179
+ # Calculate Sortino ratio
180
+ self.result[self.valid_assets] = rolling_mean.div(
181
+ self.result[self.valid_assets].replace(0, np.nan)
182
+ ) * np.sqrt(TRADING_DAYS_PER_YEAR)
183
+
184
+ return self._finalize_result(self.result[self.valid_assets])
185
+
186
+
187
+ class Volatility(RiskMetricBase):
188
+ """
189
+ Calculate annualized rolling volatility for specified assets
190
+ """
191
+
192
+ def __init__(self,
193
+ data: pd.DataFrame,
194
+ assets: List[str],
195
+ window: int = DEFAULT_VOLATILITY_WINDOW,
196
+ start: Optional[str] = None,
197
+ end: Optional[str] = None):
198
+ """
199
+ Initialize volatility calculator.
200
+
201
+ Parameters:
202
+ data (DataFrame): DataFrame with asset price data
203
+ assets (List[str]): List of asset columns to calculate for
204
+ window (int): Rolling window size in trading days
205
+ start (str, optional): Start date in format 'YYYY-MM-DD'
206
+ end (str, optional): End date in format 'YYYY-MM-DD'
207
+ """
208
+ super().__init__(
209
+ data=data,
210
+ assets=assets,
211
+ window=window,
212
+ start=start,
213
+ end=end,
214
+ use_returns=True
215
+ )
216
+
217
+ def calculate(self) -> pd.DataFrame:
218
+ """
219
+ Calculate annualized volatility for all valid assets.
220
+
221
+ Returns:
222
+ DataFrame: Volatility for specified assets
223
+ """
224
+ if not self.valid_assets:
225
+ return self.result
226
+
227
+ # Calculate rolling standard deviation and annualize for all valid assets at once
228
+ vol_result = self.data[self.valid_assets].rolling(
229
+ window=self.window,
230
+ min_periods=self.min_periods
231
+ ).std() * np.sqrt(TRADING_DAYS_PER_YEAR)
232
+
233
+ return self._finalize_result(vol_result)
234
+
235
+
236
+ class MaximumDrawdown(RiskMetricBase):
237
+ """
238
+ Calculate the maximum drawdown for selected assets over a given time period
239
+ """
240
+
241
+ def __init__(self,
242
+ data: pd.DataFrame,
243
+ assets: List[str],
244
+ window: int = DEFAULT_DRAWDOWN_WINDOW,
245
+ start: Optional[str] = None,
246
+ end: Optional[str] = None):
247
+ """
248
+ Initialize maximum drawdown calculator.
249
+
250
+ Parameters:
251
+ data (DataFrame): DataFrame with asset price data
252
+ assets (List[str]): List of asset columns to calculate drawdown for
253
+ window (int): Rolling window size in days
254
+ start (str, optional): Start date in format 'YYYY-MM-DD'
255
+ end (str, optional): End date in format 'YYYY-MM-DD'
256
+ """
257
+ super().__init__(
258
+ data=data,
259
+ assets=assets,
260
+ window=window,
261
+ start=start,
262
+ end=end,
263
+ use_returns=False,
264
+ min_periods=1
265
+ )
266
+
267
+ def calculate(self) -> pd.DataFrame:
268
+ """
269
+ Calculate maximum drawdown for all valid assets.
270
+
271
+ Returns:
272
+ DataFrame: Maximum drawdown for specified assets
273
+ """
274
+ if not self.valid_assets:
275
+ return self.result
276
+
277
+ # No need for a separate asset_data DataFrame
278
+ # Calculate rolling maximums directly using the original data
279
+ rolling_max = self.data[self.valid_assets].rolling(
280
+ window=self.window,
281
+ min_periods=self.min_periods
282
+ ).max()
283
+
284
+ # Calculate drawdown directly into result
285
+ self.result[self.valid_assets] = self.data[self.valid_assets].div(rolling_max) - 1
286
+
287
+ # Calculate rolling minimum of drawdown into result
288
+ self.result[self.valid_assets] = self.result[self.valid_assets].rolling(
289
+ window=self.window,
290
+ min_periods=self.min_periods
291
+ ).min()
292
+
293
+ return self._finalize_result(self.result[self.valid_assets])
294
+
295
+
296
+ class CalmarRatio(RiskMetricBase):
297
+ """
298
+ Calculate rolling Calmar ratios for specified assets
299
+ """
300
+
301
+ def __init__(self,
302
+ data: pd.DataFrame,
303
+ assets: List[str],
304
+ window: int = DEFAULT_CALMAR_WINDOW,
305
+ start: Optional[str] = None,
306
+ end: Optional[str] = None):
307
+ """
308
+ Initialize Calmar ratio calculator.
309
+
310
+ Parameters:
311
+ data (DataFrame): DataFrame with asset price data
312
+ assets (List[str]): List of asset columns to calculate for
313
+ window (int): Rolling window size in trading days
314
+ start (str, optional): Start date in format 'YYYY-MM-DD'
315
+ end (str, optional): End date in format 'YYYY-MM-DD'
316
+ """
317
+ super().__init__(
318
+ data=data,
319
+ assets=assets,
320
+ window=window,
321
+ start=start,
322
+ end=end,
323
+ use_returns=True
324
+ )
325
+
326
+ def calculate(self) -> pd.DataFrame:
327
+ """
328
+ Calculate Calmar ratio for all valid assets.
329
+
330
+ Returns:
331
+ DataFrame: Calmar ratios for specified assets
332
+ """
333
+ if not self.valid_assets:
334
+ return self.result
335
+
336
+ # Calculate annualized returns for the window
337
+ rolling_returns = self.data[self.valid_assets].rolling(
338
+ window=self.window,
339
+ min_periods=self.min_periods
340
+ ).mean() * TRADING_DAYS_PER_YEAR
341
+
342
+ # For maximum drawdown calculation, convert returns to cumulative returns
343
+ cum_returns = (1 + self.data[self.valid_assets]).cumprod()
344
+
345
+ # Calculate rolling maximum values directly
346
+ rolling_max = cum_returns.rolling(
347
+ window=self.window,
348
+ min_periods=self.min_periods
349
+ ).max()
350
+
351
+ # Calculate drawdown directly into result
352
+ self.result[self.valid_assets] = cum_returns.div(rolling_max) - 1
353
+
354
+ # Calculate max drawdown directly into result
355
+ self.result[self.valid_assets] = self.result[self.valid_assets].rolling(
356
+ window=self.window,
357
+ min_periods=self.min_periods
358
+ ).min()
359
+
360
+ # Calculate Calmar ratio directly into result
361
+ self.result[self.valid_assets] = rolling_returns.div(
362
+ self.result[self.valid_assets].abs().replace(0, np.nan)
363
+ )
364
+
365
+ return self._finalize_result(self.result[self.valid_assets])
366
+
367
+
368
+ class ValueAtRisk(RiskMetricBase):
369
+ """
370
+ Calculate rolling Value at Risk (VaR) for specified assets that complies with ECB requirements
371
+ """
372
+
373
+ def __init__(self,
374
+ data: pd.DataFrame,
375
+ assets: List[str],
376
+ confidence: float = 0.99, # Default to 99% as per ECB requirement
377
+ window: int = DEFAULT_VAR_WINDOW,
378
+ holding_period: int = 10, # Default 10-day holding period as per ECB requirement
379
+ start: Optional[str] = None,
380
+ end: Optional[str] = None,
381
+ method: str = 'historical',
382
+ scaling_method: str = 'sqrt_time'):
383
+ """
384
+ Initialize Value at Risk calculator.
385
+
386
+ Parameters:
387
+ data (DataFrame): DataFrame with asset price data
388
+ assets (List[str]): List of asset columns to calculate VaR for
389
+ confidence (float): Confidence level (0-1), default is 0.99 (99%) as per ECB requirement
390
+ window (int): Rolling window size in trading days
391
+ holding_period (int): Holding period in days, default is 10 as per ECB requirement
392
+ start (str, optional): Start date in format 'YYYY-MM-DD'
393
+ end (str, optional): End date in format 'YYYY-MM-DD'
394
+ method (str): Method to calculate VaR - 'historical', 'parametric'
395
+ scaling_method (str): Method to scale 1-day VaR to holding_period-day VaR - 'sqrt_time', 'overlapping'
396
+ """
397
+ # Validate parameters
398
+ if confidence <= 0 or confidence >= 1:
399
+ raise ValueError("Confidence level must be between 0 and 1")
400
+
401
+ if method not in VALID_VAR_METHODS:
402
+ raise ValueError(f"Method must be one of {VALID_VAR_METHODS}")
403
+
404
+ if holding_period < 1:
405
+ raise ValueError("Holding period must be at least 1 day")
406
+
407
+ if scaling_method not in ['sqrt_time', 'overlapping']:
408
+ raise ValueError("Scaling method must be 'sqrt_time' or 'overlapping'")
409
+
410
+ # Determine minimum window size based on holding period
411
+ min_window = max(252, 250 + holding_period) if holding_period > 1 else 252
412
+
413
+ if window < min_window:
414
+ raise ValueError(
415
+ f"Window size must be at least {min_window} trading days for a {holding_period}-day holding period")
416
+
417
+ # Initialize the base class
418
+ super().__init__(
419
+ data=data,
420
+ assets=assets,
421
+ window=window,
422
+ start=start,
423
+ end=end,
424
+ use_returns=True,
425
+ confidence=confidence,
426
+ method=method
427
+ )
428
+
429
+ self.confidence = confidence
430
+ self.method = method
431
+ self.holding_period = holding_period
432
+ self.scaling_method = scaling_method
433
+
434
+ # Calculate z-score for parametric method deterministically
435
+ if method == 'parametric':
436
+ self.z_score = stats.norm.ppf(confidence)
437
+
438
+ def _create_overlapping_returns(self, returns: pd.Series) -> np.ndarray:
439
+ """
440
+ Create overlapping n-day returns from daily returns using vectorized operations
441
+ """
442
+ n = self.holding_period
443
+ price_relatives = 1 + returns.values
444
+
445
+ # Using a sliding window approach with numpy operations
446
+ # This uses less memory by avoiding creating large temporary arrays
447
+ result = np.empty(len(returns) - n + 1)
448
+
449
+ # Use strided_app function or numba-accelerated function for very large datasets
450
+ for i in range(len(result)):
451
+ result[i] = np.prod(price_relatives[i:i + n]) - 1
452
+
453
+ return result
454
+
455
+ def _percentile_estimation(self, returns: np.ndarray) -> float:
456
+ """
457
+ Harrell-Davis percentile estimation using NumPy
458
+ """
459
+ # Handle NaN values once upfront
460
+ valid_returns = returns[~np.isnan(returns)]
461
+ n = len(valid_returns)
462
+
463
+ if n < 10:
464
+ return np.percentile(valid_returns, 100 * (1 - self.confidence))
465
+
466
+ # Sort only once
467
+ sorted_returns = np.sort(valid_returns)
468
+
469
+ # Calculate position directly
470
+ m = (n + 1) * (1 - self.confidence)
471
+ m_floor = int(np.floor(m))
472
+ m_ceil = int(np.ceil(m))
473
+
474
+ if m_floor == m_ceil:
475
+ return sorted_returns[m_floor - 1]
476
+
477
+ # Linear interpolation
478
+ weight = m - m_floor
479
+ lower_idx = max(0, m_floor - 1)
480
+ upper_idx = min(n - 1, m_ceil - 1)
481
+
482
+ return sorted_returns[lower_idx] * (1 - weight) + sorted_returns[upper_idx] * weight
483
+
484
+ def _calculate_historical_var(self, returns: np.ndarray) -> float:
485
+ """
486
+ Optimized historical VaR calculation
487
+ """
488
+ # Handle NaN values
489
+ valid_returns = returns[~np.isnan(returns)]
490
+
491
+ if len(valid_returns) < self.min_periods:
492
+ return np.nan
493
+
494
+ # Calculate VaR directly
495
+ var_value = -self._percentile_estimation(valid_returns)
496
+
497
+ # Scale to holding period if needed
498
+ if self.scaling_method == 'sqrt_time' and self.holding_period > 1:
499
+ var_value *= np.sqrt(self.holding_period)
500
+
501
+ return var_value
502
+
503
+ def _calculate_parametric_var(self, returns: np.ndarray) -> float:
504
+ """
505
+ Calculate VaR using parametric method.
506
+
507
+ Parameters:
508
+ returns (ndarray): Returns for a specific window
509
+
510
+ Returns:
511
+ float: Calculated VaR (positive value)
512
+ """
513
+ # Handle NaN values
514
+ valid_returns = returns[~np.isnan(returns)]
515
+
516
+ if len(valid_returns) < self.min_periods:
517
+ return np.nan
518
+
519
+ # Calculate mean and standard deviation
520
+ mean = np.mean(valid_returns)
521
+ std = np.std(valid_returns, ddof=1)
522
+
523
+ # Calculate 1-day parametric VaR
524
+ var_value = -(mean - (self.z_score * std))
525
+
526
+ # Scale to holding period
527
+ if self.holding_period > 1:
528
+ # For parametric method, we always use square-root-of-time rule
529
+ var_value = var_value * np.sqrt(self.holding_period)
530
+
531
+ return var_value # Return as positive value
532
+
533
+ def calculate(self) -> pd.DataFrame:
534
+ """
535
+ Calculate Value at Risk for all valid assets
536
+ """
537
+ if not self.valid_assets:
538
+ return self.result
539
+
540
+ # For overlapping returns method with holding_period > 1
541
+ if self.scaling_method == 'overlapping' and self.holding_period > 1:
542
+ for asset in self.valid_assets:
543
+ asset_returns = self.data[asset].values # Use numpy array directly
544
+
545
+ # Pre-compute overlapping returns
546
+ overlapping_returns = self._create_overlapping_returns(self.data[asset])
547
+
548
+ # Select calculation function
549
+ calculation_func = self._calculate_historical_var if self.method == 'historical' else self._calculate_parametric_var
550
+
551
+ # Prepare result dates and values
552
+ result_dates = self.data.index[self.window - 1:len(self.data) - self.holding_period + 1]
553
+ var_results = np.empty(len(result_dates))
554
+
555
+ # Calculate VaR for each window
556
+ for i in range(len(result_dates)):
557
+ start_idx = i
558
+ end_idx = i + self.window
559
+ var_results[i] = calculation_func(overlapping_returns[start_idx:end_idx])
560
+
561
+ # Assign results efficiently
562
+ self.result.loc[result_dates, asset] = var_results
563
+ else:
564
+ # Standard rolling window calculation
565
+ for asset in self.valid_assets:
566
+ # Select the calculation method
567
+ calc_func = self._calculate_historical_var if self.method == 'historical' else self._calculate_parametric_var
568
+
569
+ # Use optimized rolling window to reduce memory overhead
570
+ self.result[asset] = self._optimized_rolling_apply(
571
+ self.data[asset],
572
+ self.window,
573
+ calc_func
574
+ )
575
+
576
+ return self.result.dropna(how='all')
577
+
578
+ def _optimized_rolling_apply(self, series, window, func):
579
+ """
580
+ Optimized rolling window implementation to reduce overhead of pandas apply
581
+ """
582
+ result = pd.Series(index=series.index, dtype=float)
583
+ values = series.values
584
+
585
+ for i in range(window - 1, len(values)):
586
+ window_data = values[i - window + 1:i + 1]
587
+ result.iloc[i] = func(window_data)
588
+
589
+ return result
590
+
591
+
592
+ class ConditionalValueAtRisk(RiskMetricBase):
593
+ """
594
+ Calculate rolling Conditional Value at Risk (CVaR) for specified assets
595
+ """
596
+
597
+ def __init__(self,
598
+ data: pd.DataFrame,
599
+ assets: List[str],
600
+ confidence: float = DEFAULT_CONFIDENCE,
601
+ window: int = DEFAULT_CVAR_WINDOW,
602
+ start: Optional[str] = None,
603
+ end: Optional[str] = None,
604
+ method: str = 'historical'):
605
+ """
606
+ Initialize Conditional Value at Risk calculator.
607
+
608
+ Parameters:
609
+ data (DataFrame): DataFrame with asset price data
610
+ assets (List[str]): List of asset columns to calculate CVaR for
611
+ confidence (float): Confidence level (0-1)
612
+ window (int): Rolling window size in trading days
613
+ start (str, optional): Start date in format 'YYYY-MM-DD'
614
+ end (str, optional): End date in format 'YYYY-MM-DD'
615
+ method (str): Method to calculate CVaR - 'historical', 'parametric'
616
+ """
617
+ # Validate parameters
618
+ if confidence <= 0 or confidence >= 1:
619
+ raise ValueError("Confidence level must be between 0 and 1")
620
+
621
+ if method not in VALID_VAR_METHODS:
622
+ raise ValueError(f"Method must be one of {VALID_VAR_METHODS}")
623
+
624
+ super().__init__(
625
+ data=data,
626
+ assets=assets,
627
+ window=window,
628
+ start=start,
629
+ end=end,
630
+ use_returns=True,
631
+ confidence=confidence,
632
+ method=method
633
+ )
634
+
635
+ self.confidence = confidence
636
+ self.method = method
637
+
638
+ # Pre-calculate constants for parametric method
639
+ if method == 'parametric':
640
+ # Get the z-score for VaR deterministically
641
+ self.z_score = stats.norm.ppf(1 - self.confidence)
642
+
643
+ # Calculate the expected shortfall coefficient
644
+ # This is φ(z) / (1-α) where φ is the PDF of normal distribution
645
+ self.es_coeff = stats.norm.pdf(self.z_score) / (1 - self.confidence)
646
+
647
+ def calculate(self) -> pd.DataFrame:
648
+ """
649
+ Calculate Conditional Value at Risk for all valid assets.
650
+
651
+ Returns:
652
+ DataFrame: Conditional Value at Risk for specified assets
653
+ """
654
+ if not self.valid_assets:
655
+ return self.result
656
+
657
+ # Use a single asset_returns reference without creating a copy
658
+ asset_returns = self.data[self.valid_assets]
659
+
660
+ if self.method == 'historical':
661
+ # Define a function to calculate historical CVaR for each window
662
+ def historical_cvar(x):
663
+ # First get the VaR threshold
664
+ var_threshold = np.percentile(x, 100 * (1 - self.confidence))
665
+ # Then calculate the mean of all returns below the VaR threshold
666
+ below_var = x[x <= var_threshold]
667
+ return below_var.mean() if len(below_var) > 0 else var_threshold
668
+
669
+ # Calculate directly into result
670
+ for asset in self.valid_assets:
671
+ self.result[asset] = asset_returns[asset].rolling(
672
+ window=self.window,
673
+ min_periods=self.min_periods
674
+ ).apply(historical_cvar, raw=True)
675
+
676
+ elif self.method == 'parametric':
677
+ # Calculate rolling statistics
678
+ rolling_mean = asset_returns.rolling(
679
+ window=self.window,
680
+ min_periods=self.min_periods
681
+ ).mean()
682
+
683
+ rolling_std = asset_returns.rolling(
684
+ window=self.window,
685
+ min_periods=self.min_periods
686
+ ).std()
687
+
688
+ # Calculate parametric CVaR directly into result
689
+ from scipy.stats import norm
690
+ es_coeff = norm.pdf(self.z_score) / (1 - self.confidence)
691
+
692
+ self.result[self.valid_assets] = rolling_mean.sub(
693
+ rolling_std.mul(es_coeff)
694
+ )
695
+
696
+ # Remove NaN values
697
+ return self.result.dropna(how='all')
698
+
699
+
700
+ class SemiDeviation(RiskMetricBase):
701
+ """
702
+ Calculate semi-deviation (downside volatility) for specified assets.
703
+ """
704
+
705
+ def __init__(self,
706
+ data: pd.DataFrame,
707
+ assets: List[str],
708
+ target_return: Optional[float] = None,
709
+ window: int = DEFAULT_SEMIDEVIATION_WINDOW,
710
+ start: Optional[str] = None,
711
+ end: Optional[str] = None):
712
+ """
713
+ Initialize semi-deviation calculator.
714
+
715
+ Parameters:
716
+ data (DataFrame): DataFrame with asset price data
717
+ assets (List[str]): List of asset columns to calculate for
718
+ target_return (float, optional): Target return threshold. If None, the mean return is used
719
+ window (int): Rolling window size in trading days
720
+ start (str, optional): Start date in format 'YYYY-MM-DD'
721
+ end (str, optional): End date in format 'YYYY-MM-DD'
722
+ """
723
+ super().__init__(
724
+ data=data,
725
+ assets=assets,
726
+ window=window,
727
+ start=start,
728
+ end=end,
729
+ use_returns=True,
730
+ target_return=target_return
731
+ )
732
+
733
+ self.target_return = target_return
734
+
735
+ def calculate(self) -> pd.DataFrame:
736
+ """
737
+ Calculate semi-deviation for all valid assets.
738
+
739
+ Returns:
740
+ DataFrame: Semi-deviation for specified assets
741
+ """
742
+ if not self.valid_assets:
743
+ return self.result
744
+
745
+ asset_returns = self.data[self.valid_assets]
746
+
747
+ # Define a function to calculate semi-deviation for each window
748
+ def semi_deviation(returns):
749
+ if self.target_return is None:
750
+ # If no target return is provided, use the mean return of the window
751
+ threshold = np.mean(returns)
752
+ else:
753
+ threshold = self.target_return
754
+
755
+ # Consider only returns below the threshold
756
+ downside_returns = np.minimum(returns - threshold, 0)
757
+
758
+ # Compute semi-deviation (square root of the mean of squared deviations)
759
+ return np.sqrt(np.mean(np.square(downside_returns))) if len(returns) > 0 else np.nan
760
+
761
+ # Apply the function to each asset
762
+ for asset in self.valid_assets:
763
+ self.result[asset] = asset_returns[asset].rolling(
764
+ window=self.window,
765
+ min_periods=self.min_periods
766
+ ).apply(semi_deviation, raw=True)
767
+
768
+ # Annualize the semi-deviation
769
+ self.result[self.valid_assets] = self.result[self.valid_assets] * np.sqrt(TRADING_DAYS_PER_YEAR)
770
+
771
+ return self._finalize_result(self.result[self.valid_assets])
772
+
773
+
774
+ class AverageDrawdown(RiskMetricBase):
775
+ """
776
+ Calculate average drawdown for specified assets.
777
+ """
778
+
779
+ def __init__(self,
780
+ data: pd.DataFrame,
781
+ assets: List[str],
782
+ window: int = DEFAULT_AVGDRAWDOWN_WINDOW,
783
+ start: Optional[str] = None,
784
+ end: Optional[str] = None):
785
+ """
786
+ Initialize average drawdown calculator.
787
+
788
+ Parameters:
789
+ data (DataFrame): DataFrame with asset price data
790
+ assets (List[str]): List of asset columns to calculate drawdown for
791
+ window (int): Rolling window size in days
792
+ start (str, optional): Start date in format 'YYYY-MM-DD'
793
+ end (str, optional): End date in format 'YYYY-MM-DD'
794
+ """
795
+ super().__init__(
796
+ data=data,
797
+ assets=assets,
798
+ window=window,
799
+ start=start,
800
+ end=end,
801
+ use_returns=False,
802
+ min_periods=1
803
+ )
804
+
805
+ def calculate(self) -> pd.DataFrame:
806
+ """
807
+ Calculate average drawdown for all valid assets.
808
+
809
+ Returns:
810
+ DataFrame: Average drawdown for specified assets
811
+ """
812
+ if not self.valid_assets:
813
+ return self.result
814
+
815
+ # Calculate rolling maximum directly
816
+ rolling_max = self.data[self.valid_assets].rolling(
817
+ window=self.window,
818
+ min_periods=self.min_periods
819
+ ).max()
820
+
821
+ # Calculate drawdowns (not stored in self.result to avoid overwriting)
822
+ drawdowns = self.data[self.valid_assets].div(rolling_max) - 1
823
+
824
+ # Calculate mean of all drawdowns directly into result
825
+ # We only include non-zero drawdowns in the calculation
826
+ def avg_drawdown(drawdown_series):
827
+ # Only consider actual drawdowns (negative values)
828
+ actual_drawdowns = drawdown_series[drawdown_series < 0]
829
+
830
+ # Calculate the mean of the drawdowns if there are any
831
+ return actual_drawdowns.mean() if len(actual_drawdowns) > 0 else 0
832
+
833
+ for asset in self.valid_assets:
834
+ self.result[asset] = drawdowns[asset].rolling(
835
+ window=self.window,
836
+ min_periods=self.min_periods
837
+ ).apply(avg_drawdown)
838
+
839
+ return self._finalize_result(self.result[self.valid_assets])
840
+
841
+
842
+ class UlcerIndex(RiskMetricBase):
843
+ """
844
+ Calculate Ulcer Index for specified assets.
845
+ The Ulcer Index is the square root of the mean of the squared drawdowns.
846
+ """
847
+
848
+ def __init__(self,
849
+ data: pd.DataFrame,
850
+ assets: List[str],
851
+ window: int = DEFAULT_ULCER_WINDOW,
852
+ start: Optional[str] = None,
853
+ end: Optional[str] = None):
854
+ """
855
+ Initialize Ulcer Index calculator.
856
+
857
+ Parameters:
858
+ data (DataFrame): DataFrame with asset price data
859
+ assets (List[str]): List of asset columns to calculate for
860
+ window (int): Rolling window size in days
861
+ start (str, optional): Start date in format 'YYYY-MM-DD'
862
+ end (str, optional): End date in format 'YYYY-MM-DD'
863
+ """
864
+ super().__init__(
865
+ data=data,
866
+ assets=assets,
867
+ window=window,
868
+ start=start,
869
+ end=end,
870
+ use_returns=False,
871
+ min_periods=1
872
+ )
873
+
874
+ def calculate(self) -> pd.DataFrame:
875
+ """
876
+ Calculate Ulcer Index for all valid assets.
877
+
878
+ Returns:
879
+ DataFrame: Ulcer Index for specified assets
880
+ """
881
+ if not self.valid_assets:
882
+ return self.result
883
+
884
+ # Calculate rolling maximum directly
885
+ rolling_max = self.data[self.valid_assets].rolling(
886
+ window=self.window,
887
+ min_periods=self.min_periods
888
+ ).max()
889
+
890
+ # Calculate percentage drawdowns (not stored in self.result to avoid overwriting)
891
+ drawdowns = self.data[self.valid_assets].div(rolling_max) - 1
892
+
893
+ # Calculate Ulcer Index directly into result
894
+ def ulcer_index(drawdown_series):
895
+ # Square all drawdowns (negative values become positive after squaring)
896
+ squared_drawdowns = np.square(np.minimum(drawdown_series, 0))
897
+
898
+ # Calculate the mean of squared drawdowns and take the square root
899
+ return np.sqrt(np.mean(squared_drawdowns)) if len(drawdown_series) > 0 else 0
900
+
901
+ for asset in self.valid_assets:
902
+ self.result[asset] = drawdowns[asset].rolling(
903
+ window=self.window,
904
+ min_periods=self.min_periods
905
+ ).apply(ulcer_index)
906
+
907
+ return self._finalize_result(self.result[self.valid_assets])
908
+
909
+
910
+ class MeanAbsoluteDeviation(RiskMetricBase):
911
+ """
912
+ Calculate Mean Absolute Deviation (MAD) for specified assets.
913
+ """
914
+
915
+ def __init__(self,
916
+ data: pd.DataFrame,
917
+ assets: List[str],
918
+ window: int = DEFAULT_MAD_WINDOW,
919
+ start: Optional[str] = None,
920
+ end: Optional[str] = None):
921
+ """
922
+ Initialize Mean Absolute Deviation calculator.
923
+
924
+ Parameters:
925
+ data (DataFrame): DataFrame with asset price data
926
+ assets (List[str]): List of asset columns to calculate for
927
+ window (int): Rolling window size in trading days
928
+ start (str, optional): Start date in format 'YYYY-MM-DD'
929
+ end (str, optional): End date in format 'YYYY-MM-DD'
930
+ """
931
+ super().__init__(
932
+ data=data,
933
+ assets=assets,
934
+ window=window,
935
+ start=start,
936
+ end=end,
937
+ use_returns=True
938
+ )
939
+
940
+ def calculate(self) -> pd.DataFrame:
941
+ """
942
+ Calculate Mean Absolute Deviation for all valid assets.
943
+
944
+ Returns:
945
+ DataFrame: Mean Absolute Deviation for specified assets
946
+ """
947
+ if not self.valid_assets:
948
+ return self.result
949
+
950
+ # Define a function to calculate MAD for each window
951
+ def mad(returns):
952
+ # Calculate the mean return
953
+ mean_return = np.mean(returns)
954
+
955
+ # Calculate absolute deviations from the mean
956
+ abs_deviations = np.abs(returns - mean_return)
957
+
958
+ # Return the mean of absolute deviations
959
+ return np.mean(abs_deviations) if len(returns) > 0 else np.nan
960
+
961
+ # Apply the function to each asset
962
+ for asset in self.valid_assets:
963
+ self.result[asset] = self.data[asset].rolling(
964
+ window=self.window,
965
+ min_periods=self.min_periods
966
+ ).apply(mad, raw=True)
967
+
968
+ # Annualize the MAD
969
+ self.result[self.valid_assets] = self.result[self.valid_assets] * np.sqrt(TRADING_DAYS_PER_YEAR)
970
+
971
+ return self._finalize_result(self.result[self.valid_assets])
972
+
973
+
974
+ class EntropicRiskMeasure(RiskMetricBase):
975
+ """
976
+ Calculate the Entropic Risk Measure (ERM) for specified assets.
977
+ ERM is a coherent risk measure defined as:
978
+
979
+ ERM_α(X) = z*ln(M_X(z^(-1))/α)
980
+
981
+ where M_X(z) is the moment generating function of X.
982
+ """
983
+
984
+ def __init__(self,
985
+ data: pd.DataFrame,
986
+ assets: List[str],
987
+ z: float = 1.0,
988
+ alpha: float = DEFAULT_CONFIDENCE,
989
+ window: int = DEFAULT_VAR_WINDOW,
990
+ start: Optional[str] = None,
991
+ end: Optional[str] = None):
992
+ """
993
+ Initialize Entropic Risk Measure calculator.
994
+
995
+ Parameters:
996
+ data (DataFrame): DataFrame with asset price data
997
+ assets (List[str]): List of asset columns to calculate for
998
+ z (float): Risk aversion parameter, must be greater than zero
999
+ alpha (float): Significance level for the risk measure (0-1)
1000
+ window (int): Rolling window size in trading days
1001
+ start (str, optional): Start date in format 'YYYY-MM-DD'
1002
+ end (str, optional): End date in format 'YYYY-MM-DD'
1003
+ """
1004
+ # Validate parameters
1005
+ if z <= 0:
1006
+ raise ValueError("Risk aversion parameter z must be greater than zero")
1007
+ if alpha <= 0 or alpha >= 1:
1008
+ raise ValueError("Significance level alpha must be between 0 and 1")
1009
+
1010
+ super().__init__(
1011
+ data=data,
1012
+ assets=assets,
1013
+ window=window,
1014
+ start=start,
1015
+ end=end,
1016
+ use_returns=True,
1017
+ z=z,
1018
+ alpha=alpha
1019
+ )
1020
+
1021
+ self.z = z
1022
+ self.alpha = alpha
1023
+
1024
+ def calculate(self) -> pd.DataFrame:
1025
+ """
1026
+ Calculate Entropic Risk Measure for all valid assets.
1027
+
1028
+ Returns:
1029
+ DataFrame: Entropic Risk Measure for specified assets
1030
+ """
1031
+ if not self.valid_assets:
1032
+ return self.result
1033
+
1034
+ # Define a function to calculate ERM for each window
1035
+ def erm_calc(returns, z=self.z, alpha=self.alpha):
1036
+ # Calculate moment generating function: E[exp(-x/z)]
1037
+ mgf = np.mean(np.exp(-returns / z))
1038
+ # Calculate ERM
1039
+ return z * (np.log(mgf) + np.log(1 / alpha))
1040
+
1041
+ # Apply the ERM calculation to each asset's rolling window
1042
+ for asset in self.valid_assets:
1043
+ self.result[asset] = self.data[asset].rolling(
1044
+ window=self.window,
1045
+ min_periods=self.min_periods
1046
+ ).apply(erm_calc, raw=True)
1047
+
1048
+ return self._finalize_result(self.result[self.valid_assets])
1049
+
1050
+
1051
+ class EntropicValueAtRisk(RiskMetricBase):
1052
+ """
1053
+ Calculate the Entropic Value at Risk (EVaR) for specified assets.
1054
+ EVaR is a coherent risk measure defined as:
1055
+
1056
+ EVaR_α(X) = inf_{z>0} { z*ln(M_X(z^(-1))/α) }
1057
+
1058
+ where M_X(z) is the moment generating function of X.
1059
+
1060
+ This implementation uses convex optimization via CVXPY for accurate results
1061
+ with optimized memory usage.
1062
+ """
1063
+
1064
+ def __init__(self,
1065
+ data: pd.DataFrame,
1066
+ assets: List[str],
1067
+ alpha: float = DEFAULT_CONFIDENCE,
1068
+ window: int = DEFAULT_VAR_WINDOW,
1069
+ start: Optional[str] = None,
1070
+ end: Optional[str] = None,
1071
+ fallback_z_min: float = 0.01,
1072
+ fallback_z_max: float = 100.0,
1073
+ fallback_steps: int = 20,
1074
+ solver: str = None,
1075
+ batch_size: int = 1):
1076
+ """
1077
+ Initialize Entropic Value at Risk calculator.
1078
+
1079
+ Parameters:
1080
+ data (DataFrame): DataFrame with asset price data
1081
+ assets (List[str]): List of asset columns to calculate for
1082
+ alpha (float): Significance level for the risk measure (0-1)
1083
+ window (int): Rolling window size in trading days
1084
+ start (str, optional): Start date in format 'YYYY-MM-DD'
1085
+ end (str, optional): End date in format 'YYYY-MM-DD'
1086
+ fallback_z_min (float): Minimum value of z for fallback grid search
1087
+ fallback_z_max (float): Maximum value of z for fallback grid search
1088
+ fallback_steps (int): Number of steps for fallback grid search
1089
+ solver (str, optional): CVXPY solver to use. If None, CVXPY will choose the best available solver.
1090
+ batch_size (int): Number of assets to process in one batch for memory efficiency
1091
+ """
1092
+ # Import warnings for error handling without global import
1093
+ import warnings
1094
+
1095
+ # Try to import cvxpy only once during initialization
1096
+ try:
1097
+ import cvxpy as cp
1098
+ self.cp = cp
1099
+ self.has_cvxpy = True
1100
+ except ImportError:
1101
+ warnings.warn(
1102
+ "CVXPY not installed. Falling back to grid search optimization for EVaR. "
1103
+ "Install CVXPY for more accurate results: pip install cvxpy"
1104
+ )
1105
+ self.has_cvxpy = False
1106
+ self.cp = None
1107
+
1108
+ # Store warnings module reference
1109
+ self.warnings = warnings
1110
+
1111
+ # Validate parameters
1112
+ if alpha <= 0 or alpha >= 1:
1113
+ raise ValueError("Significance level alpha must be between 0 and 1")
1114
+
1115
+ super().__init__(
1116
+ data=data,
1117
+ assets=assets,
1118
+ window=window,
1119
+ start=start,
1120
+ end=end,
1121
+ use_returns=True,
1122
+ alpha=alpha,
1123
+ fallback_z_min=fallback_z_min,
1124
+ fallback_z_max=fallback_z_max,
1125
+ fallback_steps=fallback_steps
1126
+ )
1127
+
1128
+ self.alpha = alpha
1129
+ self.fallback_z_min = fallback_z_min
1130
+ self.fallback_z_max = fallback_z_max
1131
+ self.fallback_steps = fallback_steps
1132
+ self.solver = solver
1133
+ self.batch_size = max(1, batch_size) # Ensure batch size is at least 1
1134
+
1135
+ # Prepare log(1/alpha) value in advance
1136
+ self.log_inv_alpha = np.log(1 / alpha)
1137
+
1138
+ # Pre-compute logarithm for grid search to avoid repeated calculations
1139
+ self.log_factor = np.log(1 / alpha)
1140
+
1141
+ def _evar_optimization(self, returns):
1142
+ """
1143
+ Calculate EVaR using CVXPY optimization.
1144
+
1145
+ Parameters:
1146
+ returns (ndarray): Returns for a specific window
1147
+
1148
+ Returns:
1149
+ float: Calculated EVaR
1150
+ """
1151
+ if not self.has_cvxpy:
1152
+ return self._evar_grid_search(returns)
1153
+
1154
+ # Filter returns in-place where possible
1155
+ mask = np.isfinite(returns)
1156
+ if not np.all(mask):
1157
+ returns = returns[mask]
1158
+
1159
+ if len(returns) == 0:
1160
+ return np.nan
1161
+
1162
+ T = len(returns)
1163
+ cp = self.cp
1164
+
1165
+ try:
1166
+ # Reuse variables for the optimization problem
1167
+ t = cp.Variable((1, 1))
1168
+ z = cp.Variable((1, 1), nonneg=True)
1169
+ ui = cp.Variable((T, 1))
1170
+
1171
+ # Create ones array only once with the minimum size needed
1172
+ ones = np.ones((T, 1))
1173
+
1174
+ # Reshape returns only once and in-place where possible
1175
+ returns_array = returns.reshape(-1, 1)
1176
+
1177
+ # Scale factor for numerical stability
1178
+ scale = 1000.0
1179
+
1180
+ # Set up constraints
1181
+ constraints = [
1182
+ cp.sum(ui) <= z,
1183
+ # Use the exponential cone more efficiently
1184
+ cp.ExpCone(-returns_array * scale - t * scale, ones @ z * scale, ui * scale)
1185
+ ]
1186
+
1187
+ # Objective function for EVaR
1188
+ # Use pre-computed log value
1189
+ risk = t + z * self.log_inv_alpha * (1.0 / T)
1190
+ objective = cp.Minimize(risk * scale)
1191
+
1192
+ # Set up and solve the problem
1193
+ prob = cp.Problem(objective, constraints)
1194
+
1195
+ # Solve with minimal overhead
1196
+ # Solve with minimal overhead
1197
+ if self.solver:
1198
+ prob.solve(solver=self.solver, verbose=False)
1199
+ else:
1200
+ prob.solve(verbose=False)
1201
+
1202
+ # Extract the result efficiently
1203
+ val = risk.value
1204
+
1205
+ # Clean up large objects explicitly to help garbage collection
1206
+ del constraints, objective, prob, returns_array, ones
1207
+
1208
+ if val is None:
1209
+ return self._evar_grid_search(returns)
1210
+ else:
1211
+ return val.item()
1212
+
1213
+ except Exception as e:
1214
+ self.warnings.warn(f"CVXPY optimization failed: {str(e)}. Falling back to grid search.")
1215
+ return self._evar_grid_search(returns)
1216
+
1217
+ def _evar_grid_search(self, returns):
1218
+ """
1219
+ Calculate EVaR using grid search (fallback method) with memory optimization.
1220
+
1221
+ Parameters:
1222
+ returns (ndarray): Returns for a specific window
1223
+
1224
+ Returns:
1225
+ float: Calculated EVaR
1226
+ """
1227
+ # Filter returns in-place where possible
1228
+ mask = np.isfinite(returns)
1229
+ if not np.all(mask):
1230
+ returns = returns[mask]
1231
+
1232
+ if len(returns) == 0:
1233
+ return np.nan
1234
+
1235
+ # Generate grid of z values once
1236
+ z_values = np.linspace(self.fallback_z_min, self.fallback_z_max, self.fallback_steps)
1237
+
1238
+ # Initialize minimum value
1239
+ min_evar = float('inf')
1240
+
1241
+ # Pre-compute log factor
1242
+ log_factor = self.log_factor
1243
+
1244
+ # Loop through z values efficiently
1245
+ for z in z_values:
1246
+ if z <= 0:
1247
+ continue
1248
+
1249
+ # Vectorized calculation without intermediate arrays
1250
+ # Use single-precision where appropriate
1251
+ mgf = np.mean(np.exp(-returns / z))
1252
+ evar = z * (np.log(mgf) + log_factor)
1253
+
1254
+ # Update minimum in-place
1255
+ if evar < min_evar:
1256
+ min_evar = evar
1257
+
1258
+ return min_evar
1259
+
1260
+ def calculate(self) -> pd.DataFrame:
1261
+ """
1262
+ Calculate Entropic Value at Risk for all valid assets.
1263
+
1264
+ Returns:
1265
+ DataFrame: Entropic Value at Risk for specified assets
1266
+ """
1267
+ if not self.valid_assets:
1268
+ return self.result
1269
+
1270
+ # Process assets in batches to control memory usage
1271
+ for i in range(0, len(self.valid_assets), self.batch_size):
1272
+ batch_assets = self.valid_assets[i:i + self.batch_size]
1273
+
1274
+ for asset in batch_assets:
1275
+ # Select calculation method
1276
+ calc_method = self._evar_optimization if self.has_cvxpy else self._evar_grid_search
1277
+
1278
+ # Apply calculation to rolling window
1279
+ self.result[asset] = self.data[asset].rolling(
1280
+ window=self.window,
1281
+ min_periods=self.min_periods
1282
+ ).apply(calc_method, raw=True)
1283
+
1284
+ # Force garbage collection after each asset if batch size is 1
1285
+ if self.batch_size == 1 and i + 1 < len(self.valid_assets):
1286
+ import gc
1287
+ gc.collect()
1288
+
1289
+ return self._finalize_result(self.result[self.valid_assets])
1290
+
1291
+
1292
+ class ConditionalDrawdownAtRisk(RiskMetricBase):
1293
+ """
1294
+ Calculate the Conditional Drawdown at Risk (CDaR) for specified assets.
1295
+
1296
+ CDaR is an extension of drawdown analysis that measures the expected value
1297
+ of drawdowns exceeding a certain threshold, providing insights into tail risk
1298
+ of drawdown distributions.
1299
+ """
1300
+
1301
+ def __init__(self,
1302
+ data: pd.DataFrame,
1303
+ assets: List[str],
1304
+ confidence: float = DEFAULT_CONFIDENCE,
1305
+ window: int = DEFAULT_CDAR_WINDOW,
1306
+ start: Optional[str] = None,
1307
+ end: Optional[str] = None):
1308
+ """
1309
+ Initialize Conditional Drawdown at Risk calculator.
1310
+
1311
+ Parameters:
1312
+ data (DataFrame): DataFrame with asset price data
1313
+ assets (List[str]): List of asset columns to calculate CDaR for
1314
+ confidence (float): Confidence level (0-1)
1315
+ window (int): Rolling window size in days
1316
+ start (str, optional): Start date in format 'YYYY-MM-DD'
1317
+ end (str, optional): End date in format 'YYYY-MM-DD'
1318
+ """
1319
+ # Validate confidence level
1320
+ if confidence <= 0 or confidence >= 1:
1321
+ raise ValueError("Confidence level must be between 0 and 1")
1322
+
1323
+ super().__init__(
1324
+ data=data,
1325
+ assets=assets,
1326
+ window=window,
1327
+ start=start,
1328
+ end=end,
1329
+ use_returns=False, # Use price data directly
1330
+ min_periods=2, # Need at least 2 periods to calculate a drawdown
1331
+ confidence=confidence
1332
+ )
1333
+
1334
+ self.confidence = confidence
1335
+
1336
+ def calculate(self) -> pd.DataFrame:
1337
+ """
1338
+ Calculate Conditional Drawdown at Risk for all valid assets.
1339
+
1340
+ Returns:
1341
+ DataFrame: Conditional Drawdown at Risk for specified assets
1342
+ """
1343
+ if not self.valid_assets:
1344
+ return self.result
1345
+
1346
+ # We'll directly work with prices in this case
1347
+ asset_data = self.data[self.valid_assets]
1348
+
1349
+ for asset in self.valid_assets:
1350
+ # Function to calculate CDaR for each rolling window
1351
+ def calculate_cdar(window_prices):
1352
+ if len(window_prices) < 2:
1353
+ return np.nan
1354
+
1355
+ # Calculate drawdowns
1356
+ prices = np.array(window_prices)
1357
+ peak = np.maximum.accumulate(prices)
1358
+ # Use relative drawdowns (percentage)
1359
+ drawdowns = (peak - prices) / peak
1360
+
1361
+ # If all values are the same, drawdowns will be zero
1362
+ if np.all(drawdowns == 0):
1363
+ return 0.0
1364
+
1365
+ # Sort drawdowns to find the VaR threshold
1366
+ sorted_dd = np.sort(drawdowns)
1367
+ index = max(0, int(np.ceil(self.confidence * len(sorted_dd)) - 1))
1368
+
1369
+ dar_value = sorted_dd[index]
1370
+
1371
+ # Find drawdowns exceeding the DaR threshold
1372
+ excess_drawdowns = sorted_dd[:index + 1]
1373
+
1374
+ # Calculate CDaR
1375
+ if len(excess_drawdowns) > 0:
1376
+ cdar = np.mean(excess_drawdowns)
1377
+ else:
1378
+ cdar = dar_value
1379
+
1380
+ return cdar
1381
+
1382
+ # Apply the function to rolling windows
1383
+ self.result[asset] = asset_data[asset].rolling(
1384
+ window=self.window,
1385
+ min_periods=self.min_periods
1386
+ ).apply(calculate_cdar, raw=True)
1387
+
1388
+ # Finalize and return the result
1389
+ return self._finalize_result(self.result[self.valid_assets])
1390
+
1391
+
1392
+ class EntropicDrawdownAtRisk(RiskMetricBase):
1393
+ """
1394
+ Calculate the Entropic Drawdown at Risk (EDaR) for specified assets.
1395
+
1396
+ EDaR uses the entropy concept to provide a coherent risk measure
1397
+ for drawdowns that captures tail risk better than traditional metrics.
1398
+ """
1399
+
1400
+ def __init__(self,
1401
+ data: pd.DataFrame,
1402
+ assets: List[str],
1403
+ confidence: float = DEFAULT_CONFIDENCE,
1404
+ window: int = DEFAULT_EDAR_WINDOW,
1405
+ start: Optional[str] = None,
1406
+ end: Optional[str] = None,
1407
+ solver: Optional[str] = None,
1408
+ batch_size: int = 1,
1409
+ fallback_z_min: float = 0.01,
1410
+ fallback_z_max: float = 100.0,
1411
+ fallback_steps: int = 20):
1412
+ """
1413
+ Initialize Entropic Drawdown at Risk calculator.
1414
+
1415
+ Parameters:
1416
+ data (DataFrame): DataFrame with asset price data
1417
+ assets (List[str]): List of asset columns to calculate EDaR for
1418
+ confidence (float): Confidence level (0-1)
1419
+ window (int): Rolling window size in days
1420
+ start (str, optional): Start date in format 'YYYY-MM-DD'
1421
+ end (str, optional): End date in format 'YYYY-MM-DD'
1422
+ solver (str, optional): CVXPY solver name if available
1423
+ batch_size (int): Number of assets to process in one batch
1424
+ fallback_z_min (float): Minimum z value for grid search
1425
+ fallback_z_max (float): Maximum z value for grid search
1426
+ fallback_steps (int): Number of steps for grid search
1427
+ """
1428
+ # Validate confidence level
1429
+ if confidence <= 0 or confidence >= 1:
1430
+ raise ValueError("Confidence level must be between 0 and 1")
1431
+
1432
+ # Import warnings here for error handling
1433
+ import warnings
1434
+ self.warnings = warnings
1435
+
1436
+ # Check for CVXPY availability
1437
+ self.has_cvxpy = False
1438
+ self.cp = None
1439
+ try:
1440
+ import cvxpy as cp
1441
+ self.cp = cp
1442
+ self.has_cvxpy = True
1443
+ except ImportError:
1444
+ warnings.warn(
1445
+ "CVXPY not installed. Using grid search for EDaR calculation."
1446
+ )
1447
+
1448
+ super().__init__(
1449
+ data=data,
1450
+ assets=assets,
1451
+ window=window,
1452
+ start=start,
1453
+ end=end,
1454
+ use_returns=False, # Use price data directly
1455
+ min_periods=2, # Need at least 2 periods to calculate a drawdown
1456
+ confidence=confidence
1457
+ )
1458
+
1459
+ self.confidence = confidence
1460
+ self.solver = solver
1461
+ self.batch_size = max(1, batch_size)
1462
+ self.fallback_z_min = fallback_z_min
1463
+ self.fallback_z_max = fallback_z_max
1464
+ self.fallback_steps = fallback_steps
1465
+
1466
+ # Pre-compute logarithm for grid search
1467
+ self.log_inv_alpha = np.log(1 / confidence)
1468
+
1469
+ def _calculate_edar_grid_search(self, drawdowns):
1470
+ """
1471
+ Calculate EDaR using grid search method.
1472
+
1473
+ Parameters:
1474
+ drawdowns (ndarray): Array of drawdown values
1475
+
1476
+ Returns:
1477
+ float: EDaR value
1478
+ """
1479
+ if len(drawdowns) < 2 or np.all(drawdowns == 0):
1480
+ return 0.0
1481
+
1482
+ # Remove any NaN values
1483
+ drawdowns = drawdowns[~np.isnan(drawdowns)]
1484
+ if len(drawdowns) == 0:
1485
+ return np.nan
1486
+
1487
+ # Generate z-values for grid search
1488
+ z_values = np.linspace(self.fallback_z_min, self.fallback_z_max, self.fallback_steps)
1489
+
1490
+ min_edar = float('inf')
1491
+ for z in z_values:
1492
+ if z <= 0:
1493
+ continue
1494
+
1495
+ try:
1496
+ # Calculate moment generating function
1497
+ # Use more numerically stable computation
1498
+ max_val = np.max(drawdowns / z)
1499
+ if max_val > 50: # Avoid overflow
1500
+ # Use log-sum-exp trick for numerical stability
1501
+ shifted = drawdowns / z - max_val
1502
+ mgf = np.exp(max_val) * np.mean(np.exp(shifted))
1503
+ else:
1504
+ mgf = np.mean(np.exp(drawdowns / z))
1505
+
1506
+ # Calculate EDaR
1507
+ edar = z * (np.log(mgf) + self.log_inv_alpha)
1508
+
1509
+ # Update minimum
1510
+ if edar < min_edar:
1511
+ min_edar = edar
1512
+ except:
1513
+ # Skip errors (e.g., overflow)
1514
+ continue
1515
+
1516
+ # If we couldn't find a valid value, return NaN
1517
+ if min_edar == float('inf'):
1518
+ return np.nan
1519
+
1520
+ return min_edar
1521
+
1522
+ def _calculate_edar_cvxpy(self, drawdowns):
1523
+ """
1524
+ Calculate EDaR using CVXPY optimization.
1525
+
1526
+ Parameters:
1527
+ drawdowns (ndarray): Array of drawdown values
1528
+
1529
+ Returns:
1530
+ float: EDaR value
1531
+ """
1532
+ if not self.has_cvxpy:
1533
+ return self._calculate_edar_grid_search(drawdowns)
1534
+
1535
+ if len(drawdowns) < 2 or np.all(drawdowns == 0):
1536
+ return 0.0
1537
+
1538
+ # Remove any NaN values
1539
+ drawdowns = drawdowns[~np.isnan(drawdowns)]
1540
+ if len(drawdowns) == 0:
1541
+ return np.nan
1542
+
1543
+ cp = self.cp
1544
+ T = len(drawdowns)
1545
+
1546
+ try:
1547
+ # Set up the optimization problem
1548
+ t = cp.Variable()
1549
+ z = cp.Variable(pos=True)
1550
+
1551
+ # We'll use the dual formulation which is more stable
1552
+ constraints = []
1553
+ objective_terms = []
1554
+
1555
+ for dd in drawdowns:
1556
+ objective_terms.append(cp.exp(dd / z))
1557
+
1558
+ objective = t + z * np.log(1 / self.confidence)
1559
+ constraints.append(cp.log(cp.sum(objective_terms) / T) <= t / z)
1560
+
1561
+ # Solve the problem
1562
+ prob = cp.Problem(cp.Minimize(objective), constraints)
1563
+
1564
+ try:
1565
+ if self.solver is not None:
1566
+ prob.solve(solver=self.solver)
1567
+ else:
1568
+ prob.solve()
1569
+ except:
1570
+ # If the primary solver fails, try SCS as a backup
1571
+ try:
1572
+ prob.solve(solver='SCS')
1573
+ except:
1574
+ # Fall back to grid search if optimization fails
1575
+ return self._calculate_edar_grid_search(drawdowns)
1576
+
1577
+ # Check if we got a valid solution
1578
+ if prob.status in ["optimal", "optimal_inaccurate"]:
1579
+ return objective.value
1580
+ else:
1581
+ return self._calculate_edar_grid_search(drawdowns)
1582
+ except:
1583
+ # Fall back to grid search if there's any error
1584
+ return self._calculate_edar_grid_search(drawdowns)
1585
+
1586
+ def calculate(self) -> pd.DataFrame:
1587
+ """
1588
+ Calculate Entropic Drawdown at Risk for all valid assets.
1589
+
1590
+ Returns:
1591
+ DataFrame: Entropic Drawdown at Risk for specified assets
1592
+ """
1593
+ if not self.valid_assets:
1594
+ return self.result
1595
+
1596
+ # We'll directly work with prices
1597
+ asset_data = self.data[self.valid_assets]
1598
+
1599
+ # Process assets in batches
1600
+ for i in range(0, len(self.valid_assets), self.batch_size):
1601
+ batch_assets = self.valid_assets[i:i + self.batch_size]
1602
+
1603
+ for asset in batch_assets:
1604
+ # Function to calculate EDaR for each rolling window
1605
+ def calculate_edar(window_prices):
1606
+ if len(window_prices) < 2:
1607
+ return np.nan
1608
+
1609
+ # Calculate drawdowns
1610
+ prices = np.array(window_prices)
1611
+ peak = np.maximum.accumulate(prices)
1612
+ # Use relative drawdowns (percentage)
1613
+ drawdowns = (peak - prices) / peak
1614
+
1615
+ # Calculate EDaR
1616
+ if self.has_cvxpy:
1617
+ return self._calculate_edar_cvxpy(drawdowns)
1618
+ else:
1619
+ return self._calculate_edar_grid_search(drawdowns)
1620
+
1621
+ # Apply the function to rolling windows
1622
+ self.result[asset] = asset_data[asset].rolling(
1623
+ window=self.window,
1624
+ min_periods=self.min_periods
1625
+ ).apply(calculate_edar, raw=True)
1626
+
1627
+ # Force garbage collection if processing multiple assets
1628
+ if len(self.valid_assets) > 1:
1629
+ import gc
1630
+ gc.collect()
1631
+
1632
+ # Finalize and return the result
1633
+ return self._finalize_result(self.result[self.valid_assets])