Skip to content

Commit 31b0ae4

Browse files
committed
Add comprehensive divide by zero protection across stats functions
Protected the following functions from divide by zero warnings: - sortino: Check for zero downside deviation - ulcer_performance_index: Check for zero ulcer index - serenity_index: Check for zero std and denominator - payoff_ratio: Check for zero average loss - outlier_win_ratio: Check for no positive returns - outlier_loss_ratio: Check for no negative returns - risk_return_ratio: Check for zero standard deviation - kelly_criterion: Check for zero or NaN win/loss ratio - greeks: Check for zero benchmark variance - rolling_greeks: Use replace(0, NaN) for benchmark std All functions now return NaN instead of triggering RuntimeWarnings
1 parent 59d929f commit 31b0ae4

File tree

1 file changed

+46
-15
lines changed

1 file changed

+46
-15
lines changed

quantstats/stats.py

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,10 @@ def sortino(returns, rf=0, periods=252, annualize=True, smart=False):
938938
downside = downside * autocorr_penalty(returns)
939939

940940
# Calculate base Sortino ratio
941-
res = returns.mean() / downside
941+
if downside == 0:
942+
res = _np.nan
943+
else:
944+
res = returns.mean() / downside
942945

943946
# Annualize if requested
944947
if annualize:
@@ -1579,7 +1582,10 @@ def ulcer_performance_index(returns, rf=0):
15791582
>>> print(f"Ulcer Performance Index: {upi_value:.4f}")
15801583
"""
15811584
# Calculate excess return divided by Ulcer Index
1582-
return (comp(returns) - rf) / ulcer_index(returns)
1585+
ulcer = ulcer_index(returns)
1586+
if ulcer == 0:
1587+
return _np.nan
1588+
return (comp(returns) - rf) / ulcer
15831589

15841590

15851591
def upi(returns, rf=0):
@@ -1627,10 +1633,17 @@ def serenity_index(returns, rf=0):
16271633
dd = to_drawdown_series(returns)
16281634

16291635
# Calculate pitfall measure using conditional value at risk of drawdowns
1630-
pitfall = -cvar(dd) / returns.std()
1631-
1636+
std_returns = returns.std()
1637+
if std_returns == 0:
1638+
return _np.nan
1639+
1640+
pitfall = -cvar(dd) / std_returns
1641+
denominator = ulcer_index(returns) * pitfall
1642+
16321643
# Calculate serenity index incorporating both ulcer index and pitfall
1633-
return (returns.sum() - rf) / (ulcer_index(returns) * pitfall)
1644+
if denominator == 0:
1645+
return _np.nan
1646+
return (returns.sum() - rf) / denominator
16341647

16351648

16361649
def risk_of_ruin(returns, prepare_returns=True):
@@ -1876,7 +1889,10 @@ def payoff_ratio(returns, prepare_returns=True):
18761889
returns = _utils._prepare_returns(returns)
18771890

18781891
# Calculate ratio of average win to absolute average loss
1879-
return avg_win(returns) / abs(avg_loss(returns))
1892+
avg_loss_val = avg_loss(returns)
1893+
if avg_loss_val == 0:
1894+
return _np.nan
1895+
return avg_win(returns) / abs(avg_loss_val)
18801896

18811897

18821898
def win_loss_ratio(returns, prepare_returns=True):
@@ -1931,8 +1947,8 @@ def profit_ratio(returns, prepare_returns=True):
19311947
return float('inf')
19321948

19331949
# Calculate win and loss ratios
1934-
win_ratio = abs(wins.mean() / wins.count())
1935-
loss_ratio = abs(loss.mean() / loss.count())
1950+
win_ratio = abs(wins.mean() / wins.count()) if wins.count() > 0 else 0
1951+
loss_ratio = abs(loss.mean() / loss.count()) if loss.count() > 0 else 0
19361952

19371953
try:
19381954
if loss_ratio == 0:
@@ -2062,7 +2078,10 @@ def outlier_win_ratio(returns, quantile=0.99, prepare_returns=True):
20622078
returns = _utils._prepare_returns(returns)
20632079

20642080
# Calculate ratio of high quantile to mean positive return
2065-
return returns.quantile(quantile).mean() / returns[returns >= 0].mean()
2081+
positive_mean = returns[returns >= 0].mean()
2082+
if _pd.isna(positive_mean) or positive_mean == 0:
2083+
return _np.nan
2084+
return returns.quantile(quantile).mean() / positive_mean
20662085

20672086

20682087
def outlier_loss_ratio(returns, quantile=0.01, prepare_returns=True):
@@ -2090,7 +2109,10 @@ def outlier_loss_ratio(returns, quantile=0.01, prepare_returns=True):
20902109
returns = _utils._prepare_returns(returns)
20912110

20922111
# Calculate ratio of low quantile to mean negative return
2093-
return returns.quantile(quantile).mean() / returns[returns < 0].mean()
2112+
negative_mean = returns[returns < 0].mean()
2113+
if _pd.isna(negative_mean) or negative_mean == 0:
2114+
return _np.nan
2115+
return returns.quantile(quantile).mean() / negative_mean
20942116

20952117

20962118
def recovery_factor(returns, rf=0.0, prepare_returns=True):
@@ -2154,7 +2176,10 @@ def risk_return_ratio(returns, prepare_returns=True):
21542176
returns = _utils._prepare_returns(returns)
21552177

21562178
# Calculate mean return divided by standard deviation
2157-
return returns.mean() / returns.std()
2179+
std = returns.std()
2180+
if std == 0:
2181+
return _np.nan
2182+
return returns.mean() / std
21582183

21592184

21602185
def _get_baseline_value(prices):
@@ -2313,6 +2338,8 @@ def kelly_criterion(returns, prepare_returns=True):
23132338
win_prob = win_rate(returns)
23142339
lose_prob = 1 - win_prob
23152340

2341+
if win_loss_ratio == 0 or _pd.isna(win_loss_ratio):
2342+
return _np.nan
23162343
return ((win_loss_ratio * win_prob) - lose_prob) / win_loss_ratio
23172344

23182345

@@ -2448,7 +2475,10 @@ def greeks(returns, benchmark, periods=252.0, prepare_returns=True):
24482475
matrix = _np.cov(returns, benchmark)
24492476

24502477
# Calculate beta (sensitivity to benchmark movements)
2451-
beta = matrix[0, 1] / matrix[1, 1]
2478+
if matrix[1, 1] == 0:
2479+
beta = _np.nan
2480+
else:
2481+
beta = matrix[0, 1] / matrix[1, 1]
24522482

24532483
# Calculate alpha (excess return after adjusting for beta)
24542484
alpha = returns.mean() - beta * benchmark.mean()
@@ -2507,8 +2537,8 @@ def rolling_greeks(returns, benchmark, periods=252, prepare_returns=True):
25072537
corr = df.rolling(int(periods)).corr().unstack()["returns"]["benchmark"]
25082538
std = df.rolling(int(periods)).std()
25092539

2510-
# Calculate rolling beta
2511-
beta = corr * std["returns"] / std["benchmark"]
2540+
# Calculate rolling beta (protect against division by zero)
2541+
beta = corr * std["returns"] / std["benchmark"].replace(0, _np.nan)
25122542

25132543
# Calculate rolling alpha (not annualized for rolling version)
25142544
alpha = df["returns"].mean() - beta * df["benchmark"].mean()
@@ -2587,7 +2617,8 @@ def compare(
25872617
)
25882618

25892619
# Calculate performance multiplier and win/loss indicator
2590-
data["Multiplier"] = data["Returns"] / data["Benchmark"]
2620+
# Protect against division by zero in benchmark
2621+
data["Multiplier"] = data["Returns"] / data["Benchmark"].replace(0, _np.nan)
25912622
data["Won"] = _np.where(data["Returns"] >= data["Benchmark"], "+", "-")
25922623

25932624
# Handle DataFrame input (multiple strategies)

0 commit comments

Comments
 (0)