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
|
@@ -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])
|