Skip to content

Commit 22f6f13

Browse files
feat(financial): add value at risk and expected shortfall
1 parent f5988cc commit 22f6f13

2 files changed

Lines changed: 176 additions & 0 deletions

File tree

financial/expected_shortfall.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Expected Shortfall (ES), also known as Conditional Value at Risk (CVaR),
3+
estimated with historical simulation.
4+
5+
References:
6+
- https://en.wikipedia.org/wiki/Expected_shortfall
7+
- https://www.investopedia.com/terms/c/conditional_value_at_risk.asp
8+
9+
Expected Shortfall measures the average loss that occurs in the tail of the
10+
loss distribution beyond the Value at Risk threshold. Unlike Value at Risk,
11+
which only reports a quantile boundary, Expected Shortfall captures how bad
12+
the losses actually are when the worst cases happen, and it is a coherent
13+
risk measure.
14+
"""
15+
16+
from collections.abc import Sequence
17+
from math import isfinite
18+
19+
20+
def _linear_interpolated_quantile(
21+
sorted_values: Sequence[float], quantile: float
22+
) -> float:
23+
"""
24+
Linear interpolation between the closest ranks (NumPy default, type 7).
25+
26+
>>> _linear_interpolated_quantile([-10.0, -5.0, -2.0, 1.0, 4.0], 0.25)
27+
-5.0
28+
"""
29+
position = (len(sorted_values) - 1) * quantile
30+
lower_index = int(position)
31+
fraction = position - lower_index
32+
if lower_index == len(sorted_values) - 1:
33+
return sorted_values[-1]
34+
return sorted_values[lower_index] * (1 - fraction) + (
35+
sorted_values[lower_index + 1] * fraction
36+
)
37+
38+
39+
def expected_shortfall(
40+
returns: Sequence[float], confidence_level: float = 0.95
41+
) -> float:
42+
"""
43+
Calculate the historical-simulation Expected Shortfall of a portfolio.
44+
45+
The confidence level is the probability that the loss will not exceed the
46+
corresponding Value at Risk threshold. The tail contains every observed
47+
return at or below that threshold, and the result is the negative of the
48+
average of that tail, i.e. a positive loss magnitude when the tail contains
49+
losses.
50+
51+
Examples:
52+
>>> expected_shortfall([-10, -5, -2, 1, 4], 0.95)
53+
10.0
54+
>>> expected_shortfall([-10, -5, -2, 1, 4], 0.75)
55+
7.5
56+
>>> expected_shortfall([], 0.95)
57+
Traceback (most recent call last):
58+
...
59+
ValueError: returns must not be empty
60+
>>> expected_shortfall([-1, 0, 1], 0.0)
61+
Traceback (most recent call last):
62+
...
63+
ValueError: confidence_level must be strictly between 0 and 1
64+
>>> expected_shortfall([-1, float("inf"), 1], 0.95)
65+
Traceback (most recent call last):
66+
...
67+
ValueError: returns must contain only finite numbers
68+
69+
Time complexity: O(n log n), where n = len(returns), for sorting.
70+
Space complexity: O(n) for the sorted copy and the tail.
71+
"""
72+
if not returns:
73+
raise ValueError("returns must not be empty")
74+
if not all(isfinite(value) for value in returns):
75+
raise ValueError("returns must contain only finite numbers")
76+
if not 0 < confidence_level < 1:
77+
raise ValueError("confidence_level must be strictly between 0 and 1")
78+
79+
sorted_returns = sorted(returns)
80+
threshold = _linear_interpolated_quantile(sorted_returns, 1 - confidence_level)
81+
tail = [value for value in sorted_returns if value <= threshold]
82+
return -sum(tail) / len(tail)
83+
84+
85+
if __name__ == "__main__":
86+
import doctest
87+
88+
doctest.testmod()

financial/value_at_risk.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Value at Risk (VaR) via historical simulation.
3+
4+
References:
5+
- https://en.wikipedia.org/wiki/Value_at_risk
6+
- https://www.investopedia.com/terms/v/var.asp
7+
8+
Value at Risk measures the maximum loss a portfolio can suffer over a given
9+
period at a chosen confidence level. Historical simulation is a
10+
non-parametric method: it reuses the observed returns and reads the quantile
11+
from the empirical distribution, so no assumption is made about the shape of
12+
the loss distribution. The result is the negative of the corresponding return
13+
quantile, i.e. a positive loss magnitude when the tail of the distribution
14+
contains losses.
15+
"""
16+
17+
from collections.abc import Sequence
18+
from math import isfinite
19+
20+
21+
def _linear_interpolated_quantile(
22+
sorted_values: Sequence[float], quantile: float
23+
) -> float:
24+
"""
25+
Linear interpolation between the closest ranks (NumPy default, type 7).
26+
27+
>>> _linear_interpolated_quantile([-10.0, -5.0, -2.0, 1.0, 4.0], 0.05)
28+
-9.0
29+
>>> _linear_interpolated_quantile([1.0, 2.0, 3.0], 1.0)
30+
3.0
31+
"""
32+
position = (len(sorted_values) - 1) * quantile
33+
lower_index = int(position)
34+
fraction = position - lower_index
35+
if lower_index == len(sorted_values) - 1:
36+
return sorted_values[-1]
37+
return sorted_values[lower_index] * (1 - fraction) + (
38+
sorted_values[lower_index + 1] * fraction
39+
)
40+
41+
42+
def value_at_risk(returns: Sequence[float], confidence_level: float = 0.95) -> float:
43+
"""
44+
Calculate the historical-simulation Value at Risk of a portfolio.
45+
46+
The confidence level is the probability that the loss will not exceed the
47+
returned value. The default of 0.95 means that 95% of the observed returns
48+
are better (higher) than the VaR threshold, and the remaining 5% are worse.
49+
50+
Examples:
51+
>>> value_at_risk([-10, -5, -2, 1, 4], 0.95)
52+
9.0
53+
>>> value_at_risk([-2, -1, 0, 1, 2, 3], 0.90)
54+
1.5
55+
>>> value_at_risk([5, 10, 15], 0.75)
56+
-7.5
57+
>>> value_at_risk([], 0.95)
58+
Traceback (most recent call last):
59+
...
60+
ValueError: returns must not be empty
61+
>>> value_at_risk([-1, 0, 1], 1.0)
62+
Traceback (most recent call last):
63+
...
64+
ValueError: confidence_level must be strictly between 0 and 1
65+
>>> value_at_risk([-1, float("nan"), 1], 0.95)
66+
Traceback (most recent call last):
67+
...
68+
ValueError: returns must contain only finite numbers
69+
70+
Time complexity: O(n log n), where n = len(returns), for sorting.
71+
Space complexity: O(n) for the sorted copy.
72+
"""
73+
if not returns:
74+
raise ValueError("returns must not be empty")
75+
if not all(isfinite(value) for value in returns):
76+
raise ValueError("returns must contain only finite numbers")
77+
if not 0 < confidence_level < 1:
78+
raise ValueError("confidence_level must be strictly between 0 and 1")
79+
80+
sorted_returns = sorted(returns)
81+
threshold = _linear_interpolated_quantile(sorted_returns, 1 - confidence_level)
82+
return -threshold
83+
84+
85+
if __name__ == "__main__":
86+
import doctest
87+
88+
doctest.testmod()

0 commit comments

Comments
 (0)