Statistical metrics¶
Sharpe-based statistics consume return series. Keep per-observation and annualised units consistent, and count the model trials behind the reported result. See the Quickstart for a runnable DSR example.
purgedcv.DSRDiagnostics
dataclass
¶
DSRDiagnostics(dsr: float, observed_sr: float, sr_star: float, expected_max_z: float, var_sharpe: float, n_trials: int, n_obs: int, skew: float, kurt: float)
Return type of deflated_sharpe_ratio_full.
A frozen dataclass: read fields by attribute (diag.dsr), and call
dataclasses.asdict if you need a plain dict to serialise.
Attributes:
| Name | Type | Description |
|---|---|---|
dsr |
float
|
The deflated Sharpe probability (identical to
|
observed_sr |
float
|
Sample Sharpe ratio of |
sr_star |
float
|
Deflated benchmark in Sharpe units, i.e. the expected
maximum Sharpe of |
expected_max_z |
float
|
Standardized expected maximum (the bracket term);
|
var_sharpe |
float
|
The per-observation Sharpe variance used in the
deflation (after any |
n_trials |
int
|
The |
n_obs |
int
|
Track record length (number of observations in |
skew |
float
|
Sample skew of |
kurt |
float
|
Sample kurtosis of |
purgedcv.probabilistic_sharpe_ratio ¶
Probability that the true Sharpe ratio exceeds benchmark_skill.
Formula (Bailey & Lopez de Prado 2012, Eq. 7):
where \(\widehat{\gamma}_3\) is sample skew, \(\widehat{\gamma}_4\) is sample kurtosis (NOT excess kurtosis), and \(\Phi\) is the standard normal CDF.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
returns
|
NDArrayAny
|
1-D array of returns, length >= 2, finite values, non-zero variance. |
required |
benchmark_skill
|
float
|
The Sharpe-ratio threshold to test against. Use 0 for "is this strategy better than holding cash." |
required |
Returns:
| Type | Description |
|---|---|
float
|
Scalar probability in [0, 1]. |
Raises:
| Type | Description |
|---|---|
ValueError
|
on length < 2, non-finite values, or zero variance. |
Examples:
purgedcv.deflated_sharpe_ratio ¶
deflated_sharpe_ratio(returns: NDArrayAny, n_trials: int, var_sharpe: float, *, bars_per_year: int | None = None) -> float
Probability that the true Sharpe ratio exceeds the deflated
benchmark that accounts for n_trials independent hyperparameter
searches under the null.
Formula (Bailey & Lopez de Prado 2014):
where \(\gamma \approx 0.5772\) is the Euler-Mascheroni constant.
DSR is then probabilistic_sharpe_ratio evaluated at the
deflated benchmark \(\text{SR}^\ast_n\).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
returns
|
NDArrayAny
|
1-D array of returns (passed through to PSR). |
required |
n_trials
|
int
|
Number of independent hyperparameter searches the user
ran before reporting this strategy's Sharpe. Must be >= 1.
With |
required |
var_sharpe
|
float
|
Estimated variance of Sharpe ratios across the
UNITS: |
required |
bars_per_year
|
int | None
|
If given, |
None
|
Returns:
| Type | Description |
|---|---|
float
|
Scalar probability in [0, 1]. |
Raises:
| Type | Description |
|---|---|
TypeError
|
if |
ValueError
|
on invalid |
Examples:
>>> import numpy as np
>>> from purgedcv import deflated_sharpe_ratio
>>> rng = np.random.default_rng(0)
>>> returns = rng.normal(0.001, 0.01, 252)
>>> dsr = deflated_sharpe_ratio(returns, n_trials=50, var_sharpe=0.01**2)
>>> 0.0 <= dsr <= 1.0
True
>>> # Annualised trial-Sharpe variance: pass bars_per_year to convert.
>>> annual = deflated_sharpe_ratio(returns, 50, 0.5**2, bars_per_year=252)
>>> per_bar = deflated_sharpe_ratio(returns, 50, 0.5**2 / 252)
>>> abs(annual - per_bar) < 1e-12
True
purgedcv.deflated_sharpe_ratio_full ¶
deflated_sharpe_ratio_full(returns: NDArrayAny, n_trials: int, var_sharpe: float, *, bars_per_year: int | None = None) -> DSRDiagnostics
Like deflated_sharpe_ratio but return the intermediate
quantities alongside the probability.
When DSR is near 0 the scalar form does not tell you why: was the
deflated benchmark high because var_sharpe was large, because
n_trials was large, or was the observed Sharpe simply low? This
function returns all of those so the deflation can be inspected.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
returns
|
NDArrayAny
|
1-D array of returns. |
required |
n_trials
|
int
|
Number of independent searches (>= 1). |
required |
var_sharpe
|
float
|
Variance of Sharpe ratios across the candidates. Per
observation by default; pass |
required |
bars_per_year
|
int | None
|
If given, |
None
|
Returns:
| Type | Description |
|---|---|
DSRDiagnostics
|
A |
DSRDiagnostics
|
attribute, e.g. |
DSRDiagnostics
|
|
DSRDiagnostics
|
|
DSRDiagnostics
|
(after any |
DSRDiagnostics
|
|
Raises:
| Type | Description |
|---|---|
TypeError
|
if |
ValueError
|
on invalid |
Examples:
>>> import numpy as np
>>> from purgedcv import deflated_sharpe_ratio, deflated_sharpe_ratio_full
>>> rng = np.random.default_rng(0)
>>> returns = rng.normal(0.001, 0.01, 252)
>>> diag = deflated_sharpe_ratio_full(returns, n_trials=50, var_sharpe=0.01**2)
>>> abs(diag.dsr - deflated_sharpe_ratio(returns, 50, 0.01**2)) < 1e-12
True
>>> diag.n_obs
252
>>> diag.sr_star > 0
True
purgedcv.min_track_record_length ¶
min_track_record_length(observed_sharpe: float, target_sharpe: float, alpha: float, skew: float, kurtosis: float) -> float
Minimum sample size such that PSR(target_sharpe) >= 1 - alpha.
Inverts the probabilistic_sharpe_ratio formula for n:
Bailey & Lopez de Prado (2012, Eq. 11).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observed_sharpe
|
float
|
The sample Sharpe ratio you actually observed. |
required |
target_sharpe
|
float
|
The benchmark you want to beat with confidence. |
required |
alpha
|
float
|
Significance level in (0, 1). PSR must meet 1 - alpha. |
required |
skew
|
float
|
Sample skew of the return distribution. |
required |
kurtosis
|
float
|
Sample kurtosis (NOT excess) of the return distribution. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
float
|
The minimum number of observations, as a |
|
float
|
|
|
float
|
establish the gap, so the answer is |
|
error |
float
|
"no length is long enough" is a well-defined result. Wrap |
float
|
in |
Raises:
| Type | Description |
|---|---|
ValueError
|
if any scalar input is non-finite, if
|
Examples:
>>> import math
>>> from purgedcv import min_track_record_length
>>> n = min_track_record_length(
... observed_sharpe=0.5, target_sharpe=0.2,
... alpha=0.05, skew=0.0, kurtosis=3.0,
... )
>>> n > 0
True
>>> # No track record proves a Sharpe you have not actually beaten:
>>> math.isinf(min_track_record_length(0.1, 0.2, 0.05, 0.0, 3.0))
True
purgedcv.minimum_backtest_length ¶
Minimum backtest length, in years, below which a high in-sample Sharpe
is not evidence of skill once n_trials configurations were tried.
Run n_trials independent backtests on a strategy with no real edge
(true Sharpe 0) and the best of them shows a positive Sharpe purely by
chance. The shorter the backtest, the larger that chance maximum. This
returns the length at which the expected maximum annualised Sharpe across
n_trials trials under the null equals target_sharpe: a reported
annualised Sharpe of target_sharpe over a shorter history is within what
selection alone would produce, so it is not yet evidence of skill.
Inverts the Bailey-Lopez de Prado expected-maximum approximation for the
length y in years:
where \(\gamma\) is the Euler-Mascheroni constant and \(\text{SR}^\ast\) is
target_sharpe. The numerator is the same bracket term that deflates the
Sharpe in deflated_sharpe_ratio; MinBTL is its inverse, solving for
length instead of for a probability.
Bailey, Borwein, Lopez de Prado & Zhu (2014), "Pseudo-mathematics and financial charlatanism", Notices of the AMS 61(5). AFML Chapter 11.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_trials
|
int | integer[Any]
|
Number of independent configurations tried. Integer >= 1. |
required |
target_sharpe
|
float
|
The annualised Sharpe threshold to clear. Must be a positive finite number. Defaults to 1.0. |
1.0
|
Returns:
| Type | Description |
|---|---|
float
|
The minimum backtest length in years, as a |
float
|
the number of observations per year for a length in observations. A |
float
|
single trial ( |
float
|
is |
Raises:
| Type | Description |
|---|---|
TypeError
|
if |
ValueError
|
if |
Examples:
>>> from purgedcv import minimum_backtest_length
>>> # Trying 10 configs, an annualised Sharpe of 1 is not evidence of
>>> # skill until about 2.5 years of backtest:
>>> round(minimum_backtest_length(10, target_sharpe=1.0), 1)
2.5
>>> # More trials demand a longer backtest for the same Sharpe:
>>> minimum_backtest_length(100) > minimum_backtest_length(10)
True
>>> minimum_backtest_length(1) # a single trial: no selection
0.0
purgedcv.effective_n_trials ¶
Estimate the number of independent trials behind a correlated search.
deflated_sharpe_ratio assumes the n_trials candidates were
drawn independently. Sequential samplers (Optuna's TPE, CMA-ES) draw each
trial conditioned on the previous ones, so the trials are autocorrelated
and the raw count overstates how many independent bets were really placed.
Feeding the raw count inflates the deflated benchmark and makes DSR
needlessly conservative (often numerically zero). This returns a smaller
effective count to pass to deflated_sharpe_ratio instead.
The estimate is the run length divided by the integrated autocorrelation
time of the trial-performance series:
n_eff = n / (1 + 2 * sum_k rho_k), summing the autocorrelations
rho_k until the first non-positive lag (the initial-positive-sequence
truncation, Geyer 1992). It is a heuristic: it depends on the
truncation rule and assumes the trial order reflects the sampler's
dependence, so treat the result as an order-of-magnitude correction, not
an exact figure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trial_sharpes
|
NDArrayAny
|
1-D array of per-trial performance values (Sharpe or objective), in the order the trials were run. All finite. |
required |
method
|
str
|
Estimator to use. Only |
'autocorr'
|
Returns:
| Type | Description |
|---|---|
int
|
Effective trial count, an integer in |
int
|
series returns close to |
int
|
far fewer. A constant series returns 1 (every trial was the same). |
int
|
Fewer than 3 trials returns the raw count (too few to estimate). |
Raises:
| Type | Description |
|---|---|
ValueError
|
on an unknown |
Examples:
>>> import numpy as np
>>> from purgedcv import effective_n_trials
>>> rng = np.random.default_rng(0)
>>> indep = rng.standard_normal(400) # independent trials
>>> drift = np.cumsum(rng.standard_normal(400)) # strongly autocorrelated
>>> n_indep = effective_n_trials(indep)
>>> n_drift = effective_n_trials(drift)
>>> 1 <= n_drift < n_indep <= 400
True