conditional_value_at_risk mixes a parametric VaR threshold with an empirical tail mean, and silently returns VaR when the tail is empty

Author: WatchTree-19Created Sep 12, 2026Updated Sep 12, 2026

conditional_value_at_risk takes its threshold from value_at_risk, which is the variance-covariance estimator (norm.ppf on the fitted mean and standard deviation, as its own docstring states), and then averages the EMPIRICAL returns strictly below that parametric threshold.

The result matches neither of the two consistent estimators. It is not the Gaussian expected shortfall closed form mu - sigma * phi(z_alpha) / alpha, which is what a variance-covariance CVaR should return, and it is not an empirical expected shortfall, which would use the empirical quantile as its threshold. On fat-tailed data the fitted sigma is inflated, the parametric threshold lands where no empirical quantile lives, and the tail being averaged is arbitrarily wider or narrower than alpha of the sample. The sign of the bias depends on the distribution, so it cannot be corrected for downstream.

Reconstruction of the current behaviour matches to machine precision, on quantstats 0.0.81:

import numpy as np, pandas as pd, quantstats as qs
from scipy.stats import norm
r = 0.0004 + 0.011 * np.random.default_rng(2).standard_t(3, 1512) / np.sqrt(3.0)
s = pd.Series(r, index=pd.bdate_range("2019-01-01", periods=1512))
mu, sd = r.mean(), r.std(ddof=1)
hybrid = r[r < norm.ppf(0.05, mu, sd)].mean()
print(qs.stats.conditional_value_at_risk(s, confidence=0.95))  # -0.025780
print(hybrid)                                                  # -0.025780 (identical)
print(mu - sd * norm.pdf(norm.ppf(0.05)) / 0.05)               # -0.021047 Gaussian ES
# empirical Rockafellar-Uryasev ES on the same sample:           -0.023431

The second half of this is worse than the estimator mismatch. The function ends with

c_var = returns[returns < var].values.mean()
return c_var if ~_np.isnan(c_var) else var

so whenever no observation sits below the parametric threshold, the empty mean gives NaN and the function returns the VaR itself. CVaR then equals VaR exactly, which is impossible for a real expected shortfall on a continuous distribution, where ES is strictly more extreme than VaR. The caller gets a number that looks like a tail statistic and is in fact a statement that the tail was empty.

This is not a contrived case. It fires on any all-positive series and on short samples:

all-positive, n=756:   VaR -0.00065106  CVaR -0.00065106  observations below threshold: 0
n=7 sample:            VaR -0.02611821  CVaR -0.02611821  observations below threshold: 0

A fund with no down days in the window, or a short backtest, silently reports expected shortfall equal to value at risk.

Suggested fix, in two parts. Commit to one estimator: if the library's VaR is variance-covariance then CVaR should be the Gaussian ES closed form, and if the intent is empirical then both the threshold and the mean should be empirical. And in the empty-tail case return NaN rather than VaR, so that "no observations in the tail" is visible instead of being disguised as a tail statistic.

Happy to open a PR for either or both, with regression tests that fail on the current code. I am aware the repository has been quiet for a while, so this is filed for the record as much as anything.

Context: this came out of a reconciliation of VaR, CVaR and drawdown estimators across four Python portfolio libraries, written up at https://github.com/WatchTree-19/risk-metric-parity with a standalone reproduction script. Related, already filed: #546.