Skip to content

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 deflated_sharpe_ratio for the same inputs).

observed_sr float

Sample Sharpe ratio of returns (population standard deviation, ddof=0). In the same per-period units as var_sharpe must be; see deflated_sharpe_ratio_full.

sr_star float

Deflated benchmark in Sharpe units, i.e. the expected maximum Sharpe of n_trials candidates under the null.

expected_max_z float

Standardized expected maximum (the bracket term); sr_star = sqrt(var_sharpe) * expected_max_z.

var_sharpe float

The per-observation Sharpe variance used in the deflation (after any bars_per_year conversion), so that sr_star == sqrt(var_sharpe) * expected_max_z holds.

n_trials int

The n_trials passed in.

n_obs int

Track record length (number of observations in returns).

skew float

Sample skew of returns.

kurt float

Sample kurtosis of returns (NOT excess).

purgedcv.probabilistic_sharpe_ratio

probabilistic_sharpe_ratio(returns: NDArrayAny, benchmark_skill: float) -> float

Probability that the true Sharpe ratio exceeds benchmark_skill.

Formula (Bailey & Lopez de Prado 2012, Eq. 7):

\[ \text{PSR}(\text{SR}^\ast) = \Phi\!\left( \frac{(\widehat{\text{SR}} - \text{SR}^\ast)\sqrt{n - 1}} {\sqrt{1 - \widehat{\gamma}_3\,\widehat{\text{SR}} + \frac{\widehat{\gamma}_4 - 1}{4}\,\widehat{\text{SR}}^{\,2}}} \right) \]

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:

>>> import numpy as np
>>> from purgedcv import probabilistic_sharpe_ratio
>>> rng = np.random.default_rng(0)
>>> returns = rng.normal(0.001, 0.01, 252)
>>> psr = probabilistic_sharpe_ratio(returns, benchmark_skill=0.0)
>>> 0.0 <= psr <= 1.0
True

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):

\[ \text{SR}^\ast_{n} = \sqrt{V[\text{SR}]} \left[ (1 - \gamma) \Phi^{-1}\!\left(1 - \frac{1}{n_{\text{trials}}}\right) + \gamma \Phi^{-1}\!\left(1 - \frac{1}{n_{\text{trials}} \cdot e}\right) \right] \]

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 n_trials == 1 there is no correction to apply and DSR reduces to probabilistic_sharpe_ratio(returns, 0.0).

required
var_sharpe float

Estimated variance of Sharpe ratios across the n_trials candidates. Caller supplies; we do not estimate it because that would require knowing the distribution of submitted strategies, which is private to the caller. purgedcv.optuna_integration.TrialSharpeRecorder produces it directly from an Optuna study.

UNITS: var_sharpe must be in the same Sharpe units as the per-observation Sharpe of returns (DSR is intrinsically per-observation). If your trial Sharpes were annualised, pass bars_per_year and the conversion is done for you; otherwise var_sharpe is taken as already per-observation. Note that path_metrics annualises its Sharpe when given bars_per_year, so a var taken from its output is annualised: pass the same bars_per_year here.

required
bars_per_year int | None

If given, var_sharpe is interpreted as an annualised Sharpe variance and converted to per-observation (var_sharpe / bars_per_year) before deflation. None (default) treats var_sharpe as already per-observation and leaves prior behaviour unchanged.

None

Returns:

Type Description
float

Scalar probability in [0, 1].

Raises:

Type Description
TypeError

if n_trials is not an integer.

ValueError

on invalid n_trials, non-finite/negative var_sharpe, or non-positive bars_per_year.

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 bars_per_year if it is annualised (see deflated_sharpe_ratio for the unit contract).

required
bars_per_year int | None

If given, var_sharpe is annualised and converted to per-observation before deflation. None (default) treats it as already per-observation.

None

Returns:

Type Description
DSRDiagnostics

A DSRDiagnostics (frozen dataclass; read fields by

DSRDiagnostics

attribute, e.g. diag.dsr, diag.sr_star). dsr equals

DSRDiagnostics

deflated_sharpe_ratio for the same arguments. The

DSRDiagnostics

var_sharpe field holds the per-observation value actually used

DSRDiagnostics

(after any bars_per_year conversion), so

DSRDiagnostics

sr_star == sqrt(var_sharpe) * expected_max_z always holds.

Raises:

Type Description
TypeError

if n_trials is not an integer.

ValueError

on invalid n_trials, non-finite/negative var_sharpe, non-positive bars_per_year, or a degenerate returns series.

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:

\[ n^\ast = 1 + \left\lceil \left(\frac{\Phi^{-1}(1 - \alpha) \cdot \sqrt{1 - \gamma_3 \widehat{\text{SR}} + \frac{\gamma_4 - 1}{4} \widehat{\text{SR}}^2}} {\widehat{\text{SR}} - \text{SR}^\ast} \right)^2 \right\rceil \]

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. When

float

observed_sharpe <= target_sharpe no finite track record can

float

establish the gap, so the answer is math.inf rather than an

error float

"no length is long enough" is a well-defined result. Wrap

float

in int(...) for a count when the value is finite.

Raises:

Type Description
ValueError

if any scalar input is non-finite, if alpha not in (0, 1), or if the higher moments are too extreme for the Gaussian approximation.

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(n_trials: int | integer[Any], target_sharpe: float = 1.0) -> float

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:

\[ \text{MinBTL} = \left( \frac{(1 - \gamma)\,\Phi^{-1}\!\left(1 - \frac{1}{n_{\text{trials}}}\right) + \gamma\,\Phi^{-1}\!\left(1 - \frac{1}{n_{\text{trials}} \cdot e}\right)} {\text{SR}^\ast} \right)^2 \]

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. Multiply by

float

the number of observations per year for a length in observations. A

float

single trial (n_trials == 1) involves no selection, so the bound

float

is 0.0.

Raises:

Type Description
TypeError

if n_trials is not an integer.

ValueError

if n_trials < 1 or target_sharpe is not a positive finite number.

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

effective_n_trials(trial_sharpes: NDArrayAny, method: str = 'autocorr') -> int

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" is implemented.

'autocorr'

Returns:

Type Description
int

Effective trial count, an integer in [1, n]. A near-independent

int

series returns close to n; a strongly autocorrelated one returns

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 method, an empty or non-1-D array, or non-finite values.

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