File size: 5,027 Bytes
3339913
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
"""Probabilistic and Deflated Sharpe Ratios.

Bailey & López de Prado (2014), "The Deflated Sharpe Ratio: Correcting for
Selection Bias, Backtest Overfitting and Non-Normality".

The intuition: if you try 200 strategy variants, the best one will show a
handsome Sharpe *even when none of them has any edge*. The Deflated Sharpe
Ratio asks whether the winner beats what the luckiest of 200 coin-flippers
would have produced, and it charges extra for fat tails and negative skew --
exactly the return shapes that make naive Sharpe ratios flatter.
"""

from __future__ import annotations

import numpy as np
from scipy import stats

__all__ = [
    "probabilistic_sharpe_ratio",
    "expected_max_sharpe",
    "deflated_sharpe_ratio",
    "min_track_record_length",
]

_EULER = 0.5772156649015329


def _moments(returns: np.ndarray) -> tuple[float, float]:
    """Sample skew and *non-excess* kurtosis, as the PSR formula expects."""
    arr = np.asarray(returns, dtype=float)
    arr = arr[np.isfinite(arr)]
    if arr.size < 4:
        return 0.0, 3.0
    return float(stats.skew(arr, bias=False)), float(stats.kurtosis(arr, bias=False) + 3.0)


def probabilistic_sharpe_ratio(
    sharpe: float,
    n_obs: int,
    skew: float = 0.0,
    kurtosis: float = 3.0,
    benchmark: float = 0.0,
) -> float:
    """P(true Sharpe > ``benchmark``) given the observed Sharpe and its shape.

    ``sharpe`` and ``benchmark`` are per-observation (i.e. *not* annualised).
    """
    if n_obs < 3:
        return 0.5
    denom = 1.0 - skew * sharpe + ((kurtosis - 1.0) / 4.0) * sharpe**2
    if denom <= 0:
        return 0.5
    z = (sharpe - benchmark) * np.sqrt(n_obs - 1) / np.sqrt(denom)
    return float(stats.norm.cdf(z))


def expected_max_sharpe(n_trials: int, variance_of_trials: float) -> float:
    """Expected maximum Sharpe across ``n_trials`` *skill-free* strategies.

    This is the bar the winner has to clear to be interesting. It grows with
    the number of things you tried — which is why "I found a strategy with
    Sharpe 2" means nothing until you say how many you looked at.
    """
    n = max(int(n_trials), 1)
    if n == 1 or variance_of_trials <= 0:
        return 0.0
    sd = np.sqrt(variance_of_trials)
    # Bailey & López de Prado's Gumbel-based approximation.
    q1 = stats.norm.ppf(1.0 - 1.0 / n)
    q2 = stats.norm.ppf(1.0 - 1.0 / (n * np.e))
    return float(sd * ((1.0 - _EULER) * q1 + _EULER * q2))


def deflated_sharpe_ratio(
    returns,
    sharpe_annual: float,
    periods_per_year: int,
    n_trials: int,
    trial_sharpes=None,
    variance_of_trials: float | None = None,
) -> dict:
    """Deflate an annualised Sharpe for selection bias and non-normality.

    Returns a dict with the PSR against a zero benchmark, the selection-bias
    threshold, the deflated probability, and the inputs used, so the UI can
    show its working rather than just a number.
    """
    arr = np.asarray(returns, dtype=float)
    arr = arr[np.isfinite(arr)]
    n_obs = arr.size
    sr_per_period = sharpe_annual / np.sqrt(periods_per_year)
    skew, kurt = _moments(arr)

    if variance_of_trials is None:
        if trial_sharpes is not None and len(trial_sharpes) > 1:
            trials = np.asarray(trial_sharpes, dtype=float) / np.sqrt(periods_per_year)
            trials = trials[np.isfinite(trials)]
            variance_of_trials = float(np.var(trials, ddof=1)) if trials.size > 1 else 0.0
        else:
            # With no trial cloud to measure, fall back to the asymptotic
            # variance of a skill-free Sharpe estimate.
            variance_of_trials = 1.0 / max(n_obs - 1, 1)

    threshold = expected_max_sharpe(n_trials, variance_of_trials)

    psr = probabilistic_sharpe_ratio(sr_per_period, n_obs, skew, kurt, 0.0)
    dsr = probabilistic_sharpe_ratio(sr_per_period, n_obs, skew, kurt, threshold)

    return {
        "psr": float(psr),
        "dsr": float(dsr),
        "sr_per_period": float(sr_per_period),
        "threshold_sr_per_period": float(threshold),
        "threshold_sr_annual": float(threshold * np.sqrt(periods_per_year)),
        "n_obs": int(n_obs),
        "n_trials": int(n_trials),
        "skew": float(skew),
        "kurtosis": float(kurt),
        "variance_of_trials": float(variance_of_trials),
    }


def min_track_record_length(
    sharpe: float,
    n_obs: int,
    skew: float = 0.0,
    kurtosis: float = 3.0,
    benchmark: float = 0.0,
    confidence: float = 0.95,
) -> float:
    """Observations needed before the Sharpe is significant at ``confidence``.

    Inputs are per-observation. Returns ``inf`` when the edge is too small to
    ever clear the bar.
    """
    if sharpe <= benchmark:
        return float("inf")
    z = stats.norm.ppf(confidence)
    denom = (sharpe - benchmark) ** 2
    if denom <= 0:
        return float("inf")
    numer = 1.0 - skew * sharpe + ((kurtosis - 1.0) / 4.0) * sharpe**2
    if numer <= 0:
        return float("inf")
    return float(1.0 + numer * (z / (sharpe - benchmark)) ** 2)