Probabilistic Sharpe and Sortino ratios

From ratios to probabilities

Sharpe and Sortino answer how much reward was observed per unit of risk. PSR and PSoR answer a different question: how strong is the statistical evidence that the corresponding population ratio exceeds a chosen threshold?

That distinction matters because a large ratio from a short, skewed, or fat-tailed sample can be much less persuasive than the same ratio from a long, well-behaved sample. This notebook derives all four measures using QRT’s exact conventions, validates manual calculations against q.stats, and visualizes how confidence changes with the threshold.

The examples are educational diagnostics, not forecasts. None of these statistics removes selection bias, regime change, transaction costs, or dependence between observations.

Why visualize a probability curve?

A single PSR or PSoR value answers one question at one chosen threshold. For example, PSR @ Th = 0 asks whether the population Sharpe ratio is positive. The threshold curve answers the broader question: how does the evidence change as the required quality level increases?

The visualization is useful because it lets you:

  • assess robustness instead of relying on one headline probability;
  • compare several annualized ratio hurdles without recalculating each one manually;
  • find the largest threshold supported at a chosen confidence level, such as 95%;
  • distinguish a high observed ratio from strong statistical evidence;
  • compare confidence based on total volatility (PSR) with confidence based on downside risk (PSoR); and
  • see when a conclusion may depend on limited data, especially a small number of negative observations.

The X-axis is the annualized Sharpe or Sortino threshold. The Y-axis is the estimated probability that the corresponding population ratio exceeds that threshold. The curve must decrease from left to right because exceeding a more demanding hurdle is less likely.

1. Imports and reproducible return series

We use 252 observations per year and a fixed random seed. Four synthetic series isolate common distribution shapes; bundled AAPL returns provide a realistic, offline example. Keeping periods_per_year explicit prevents daily and annualized ratios from being mixed accidentally.

import numpy as np
import pandas as pd
from scipy.stats import norm

import qrt as q

pd.options.display.float_format = "{:.4f}".format
rng = np.random.default_rng(42)
periods_per_year = 252
n_observations = 756
dates = pd.bdate_range("2023-01-02", periods=n_observations)

normal_returns = pd.Series(rng.normal(0.0005, 0.012, n_observations), index=dates, name="Normal")
negative_skew_returns = pd.Series(
    0.0015 - rng.lognormal(-5.2, 0.9, n_observations), index=dates, name="Negative skew"
 )
fat_tailed_returns = pd.Series(
    0.0005 + rng.standard_t(4, n_observations) * 0.0085, index=dates, name="Fat tailed"
 )
lottery_returns = pd.Series(
    np.where(rng.random(n_observations) < 0.025, -0.075, 0.0025),
    index=dates,
    name="Frequent gains / rare losses",
)

synthetic_returns = {
    series.name: series
    for series in [normal_returns, negative_skew_returns, fat_tailed_returns, lottery_returns]
}

aapl = q.data.datasets.load("aapl")
returns = aapl["close"].pct_change().dropna().loc["2016":].rename("AAPL")
returns.describe()
count   2653.0000
mean       0.0011
std        0.0182
min       -0.1286
25%       -0.0071
50%        0.0011
75%        0.0100
max        0.1533
Name: AAPL, dtype: float64

2. Sharpe ratio: reward per unit of total variability

For periodic returns \(r_t\) and annualized risk-free rate \(r_f\), QRT first converts the risk-free rate to a per-period rate:

\[r_{f,p}=(1+r_f)^{1/q}-1,\]

where \(q\) is the number of periods per year. With excess returns \(x_t=r_t-r_{f,p}\), QRT computes

\[\widehat{SR}_p=\frac{\bar{x}}{s_x}, \qquad \widehat{SR}_{ann}=\sqrt{q}\,\widehat{SR}_p,\]

where \(s_x\) is the sample standard deviation (ddof=1). Sharpe is useful when upside and downside variability both represent risk, or when strategies have approximately symmetric return distributions. It is easy to compare, but can be distorted by outliers, negative skew, fat tails, stale prices, serial correlation, a mismatched risk-free rate, or an inappropriate annualization frequency. Set smart=True to apply QRT’s autocorrelation penalty to the denominator.

annual_rf = 0.0
periodic_rf = (1.0 + annual_rf) ** (1.0 / periods_per_year) - 1.0
excess_returns = returns - periodic_rf

sharpe_periodic_manual = excess_returns.mean() / excess_returns.std(ddof=1)
sharpe_annual_manual = sharpe_periodic_manual * np.sqrt(periods_per_year)
sharpe_annual_qrt = q.stats.sharpe(
    returns, periods_per_year=periods_per_year, rf=annual_rf
)

pd.Series(
    {
        "Periodic Sharpe (manual)": sharpe_periodic_manual,
        "Annualized Sharpe (manual)": sharpe_annual_manual,
        "Annualized Sharpe (q.stats)": sharpe_annual_qrt,
    },
    name="AAPL",
)
Periodic Sharpe (manual)      0.0631
Annualized Sharpe (manual)    1.0018
Annualized Sharpe (q.stats)   1.0018
Name: AAPL, dtype: float64

3. Sortino ratio: reward relative to harmful variation

Sortino replaces total volatility with downside deviation. For a minimum acceptable return \(T_p\), define

\[d_t=\min(r_t-T_p,0), \qquad DD=\sqrt{\frac{1}{n}\sum_{t=1}^{n}d_t^2},\]

and

\[\widehat{SoR}_p=\frac{\overline{r-T_p}}{DD}, \qquad \widehat{SoR}_{ann}=\sqrt{q}\,\widehat{SoR}_p.\]

This is useful when positive volatility should not be penalized. QRT uses the deannualized rf as \(T_p\), squares the clipped downside for all \(n\) observations (zeros included), and uses no Bessel correction. This differs from implementations that take the standard deviation of only negative returns. The rf/minimum-acceptable-return changes which observations count as downside; it is separate from the PSoR ratio threshold introduced later. Sortino can still be unstable when downside observations are scarce or dominated by a few losses.

downside = np.minimum(excess_returns.to_numpy(), 0.0)
downside_deviation = np.sqrt(np.mean(downside**2))
sortino_periodic_manual = excess_returns.mean() / downside_deviation
sortino_annual_manual = sortino_periodic_manual * np.sqrt(periods_per_year)
sortino_annual_qrt = q.stats.sortino(
    returns, periods_per_year=periods_per_year, rf=annual_rf
)

target_comparison = pd.Series(
    {
        f"Sortino @ annual MAR = {target:.0%}": q.stats.sortino(
            returns, periods_per_year=periods_per_year, rf=target
        )
        for target in [0.0, 0.02, 0.05]
    },
)
pd.concat(
    [
        pd.Series(
            {
                "Periodic Sortino (manual)": sortino_periodic_manual,
                "Annualized Sortino (manual)": sortino_annual_manual,
                "Annualized Sortino (q.stats)": sortino_annual_qrt,
            }
        ),
        target_comparison,
    ]
)
Periodic Sortino (manual)      0.0933
Annualized Sortino (manual)    1.4810
Annualized Sortino (q.stats)   1.4810
Sortino @ annual MAR = 0%      1.4810
Sortino @ annual MAR = 2%      1.3757
Sortino @ annual MAR = 5%      1.2225
dtype: float64

4. Sampling uncertainty and higher moments

Sharpe and Sortino are point estimates: they describe this sample as if the estimated ratio were known exactly. Their uncertainty depends on sample size and return shape. More observations usually tighten inference; negative skew and excess kurtosis indicate asymmetric or unusually frequent extreme outcomes that make normal-theory confidence less persuasive.

PSR and PSoR turn a point estimate into a probability relative to an explicit benchmark. They do not mean “probability the strategy will be profitable next year.” They estimate confidence that the population risk-adjusted ratio exceeds the selected ratio threshold, conditional on the model and sample.

distribution_diagnostics = pd.DataFrame(
    {
        name: {
            "Observations": len(series),
            "Mean": series.mean(),
            "Volatility": series.std(ddof=1),
            "Skewness": series.skew(),
            "Excess kurtosis": series.kurtosis(),
        }
        for name, series in {**synthetic_returns, "AAPL": returns}.items()
    }
).T
distribution_diagnostics
Observations Mean Volatility Skewness Excess kurtosis
Normal 756.0000 0.0000 0.0119 0.0321 -0.0791
Negative skew 756.0000 -0.0067 0.0088 -3.5495 21.1854
Fat tailed 756.0000 -0.0002 0.0116 -0.1735 2.9131
Frequent gains / rare losses 756.0000 0.0006 0.0121 -6.0796 35.0546
AAPL 2653.0000 0.0011 0.0182 0.1331 6.7656

5. Probabilistic Sharpe Ratio (PSR)

For an annualized benchmark \(SR^*_{ann}\), QRT first converts both the observed ratio and threshold to periodic units:

\[\widehat{SR}_p=\frac{\widehat{SR}_{ann}}{\sqrt q}, \qquad SR^*_p=\frac{SR^*_{ann}}{\sqrt q}.\]

Let \(n\) be the full sample size, \(\gamma_3\) sample skewness, and \(\gamma_4\) raw kurtosis (Pandas excess kurtosis plus 3). QRT implements the Bailey and Lopez de Prado adjustment

\[PSR=\Phi\left(\frac{(\widehat{SR}_p-SR^*_p)\sqrt{n-1}}{\sqrt{1-\gamma_3\widehat{SR}_p+\frac{\gamma_4-1}{4}\widehat{SR}_p^2}}\right).\]

A PSR near 1 is stronger in-sample evidence that Sharpe exceeds the threshold; it is not a guarantee of future performance. At a threshold equal to the observed Sharpe, the numerator is zero, so \(PSR=\Phi(0)=50\%\).

sharpe_threshold = 0.0
sharpe_periodic = sharpe_annual_qrt / np.sqrt(periods_per_year)
sharpe_threshold_periodic = sharpe_threshold / np.sqrt(periods_per_year)
sample_skew = returns.skew()
raw_kurtosis = returns.kurtosis() + 3.0
psr_variance = (
    1.0
    - sample_skew * sharpe_periodic
    + (raw_kurtosis - 1.0) / 4.0 * sharpe_periodic**2
)
psr_z = (
    (sharpe_periodic - sharpe_threshold_periodic)
    * np.sqrt(len(returns) - 1)
    / np.sqrt(psr_variance)
)
psr_manual = norm.cdf(psr_z)
psr_qrt = q.stats.probabilistic_ratio(
    returns,
    threshold=sharpe_threshold,
    periods_per_year=periods_per_year,
    rf=annual_rf,
)

pd.Series(
    {
        "Annualized Sharpe": sharpe_annual_qrt,
        "Annualized threshold": sharpe_threshold,
        "PSR probability": psr_qrt,
        "PSR percentage": f"{psr_qrt:.2%}",
    }
)
Annualized Sharpe       1.0018
Annualized threshold    0.0000
PSR probability         0.9994
PSR percentage          99.94%
dtype: object

6. Probabilistic Sortino Ratio (PSoR)

PSoR estimates \(P(SoR>SoR^*)\) and is the confidence-aware counterpart to Sortino. Two thresholds must not be confused:

  • rf is the annualized minimum acceptable return used to construct excess returns and downside observations.
  • threshold is the annualized Sortino-ratio benchmark that the unknown population Sortino should exceed.

QRT converts observed Sortino and \(SoR^*\) to periodic units. It then keeps only negative excess returns, giving downside count \(n_d\) and downside skewness \(\gamma_3^-\). Its downside-specific approximation is

\[V_{SoR}=1+\frac{\widehat{SoR}_p^2}{2}-\gamma_3^-\widehat{SoR}_p,\]

\[PSoR=\Phi\left(\frac{(\widehat{SoR}_p-SoR^*_p)\sqrt{n_d-1}}{\sqrt{V_{SoR}}}\right).\]

Unlike PSR, QRT’s PSoR does not use full-sample kurtosis. It returns NaN when fewer than three downside observations are available or the estimated variance is not positive. Because \(n_d\) may be much smaller than \(n\), PSoR can express substantially more uncertainty than a large Sortino point estimate suggests.

sortino_threshold = 0.0
sortino_periodic = sortino_annual_qrt / np.sqrt(periods_per_year)
sortino_threshold_periodic = sortino_threshold / np.sqrt(periods_per_year)
negative_excess = excess_returns[excess_returns < 0.0]
downside_count = len(negative_excess)
downside_skew = negative_excess.skew()
psor_variance = 1.0 + sortino_periodic**2 / 2.0 - downside_skew * sortino_periodic
psor_z = (
    (sortino_periodic - sortino_threshold_periodic)
    * np.sqrt(downside_count - 1)
    / np.sqrt(psor_variance)
)
psor_manual = norm.cdf(psor_z)
psor_qrt = q.stats.probabilistic_sortino_ratio(
    returns,
    threshold=sortino_threshold,
    periods_per_year=periods_per_year,
    rf=annual_rf,
)

pd.Series(
    {
        "Annualized Sortino": sortino_annual_qrt,
        "Annualized ratio threshold": sortino_threshold,
        "Annualized minimum acceptable return": annual_rf,
        "Downside observations": downside_count,
        "Downside skewness": downside_skew,
        "PSoR probability": psor_qrt,
        "PSoR percentage": f"{psor_qrt:.2%}",
    }
)
Annualized Sortino                      1.4810
Annualized ratio threshold              0.0000
Annualized minimum acceptable return    0.0000
Downside observations                     1225
Downside skewness                      -2.5726
PSoR probability                        0.9983
PSoR percentage                         99.83%
dtype: object

7. Validate the manual calculations

The public APIs accept a Pandas return Series and return a Python float probability in \([0,1]\) when defined. Both threshold arguments are annualized ratio thresholds and default to 0; rf is an annualized rate and also defaults to 0. periods_per_year=None infers frequency from the index, while this notebook passes 252 explicitly.

The assertions below protect against a hidden unit mismatch: manual and library results must agree, and a threshold equal to the observed annualized ratio must produce 50%.

validation = pd.DataFrame(
    {
        "Manual": [sharpe_annual_manual, sortino_annual_manual, psr_manual, psor_manual],
        "QRT": [sharpe_annual_qrt, sortino_annual_qrt, psr_qrt, psor_qrt],
    },
    index=["Sharpe", "Sortino", "PSR @ Th = 0", "PSoR @ Th = 0"],
)
validation["Absolute difference"] = (validation["Manual"] - validation["QRT"]).abs()

assert np.allclose(validation["Manual"], validation["QRT"], rtol=1e-12, atol=1e-12)
assert np.isclose(
    q.stats.probabilistic_ratio(
        returns, threshold=sharpe_annual_qrt, periods_per_year=periods_per_year
    ),
    0.5,
)
assert np.isclose(
    q.stats.probabilistic_sortino_ratio(
        returns, threshold=sortino_annual_qrt, periods_per_year=periods_per_year
    ),
    0.5,
)
validation
Manual QRT Absolute difference
Sharpe 1.0018 1.0018 0.0000
Sortino 1.4810 1.4810 0.0000
PSR @ Th = 0 0.9994 0.9994 0.0000
PSoR @ Th = 0 0.9983 0.9983 0.0000

8. Compare return distributions

The same mean and volatility do not imply the same evidence. PSR responds to full-sample size, skewness, and kurtosis; PSoR responds to the number and skewness of downside observations. Sortino can greatly exceed Sharpe for a stream with many small positive moves, but PSoR should temper that point estimate when only a small or highly skewed downside sample supports it.

all_returns = {**synthetic_returns, "AAPL": returns}
ratio_comparison = pd.DataFrame(
    {
        name: {
            "Sharpe": q.stats.sharpe(series, periods_per_year=periods_per_year),
            "Sortino": q.stats.sortino(series, periods_per_year=periods_per_year),
            "PSR @ Th = 0": q.stats.probabilistic_ratio(
                series, threshold=0.0, periods_per_year=periods_per_year
            ),
            "PSoR @ Th = 0": q.stats.probabilistic_sortino_ratio(
                series, threshold=0.0, periods_per_year=periods_per_year
            ),
            "Downside observations": int((series < 0.0).sum()),
        }
        for name, series in all_returns.items()
    }
).T
ratio_comparison
Sharpe Sortino PSR @ Th = 0 PSoR @ Th = 0 Downside observations
Normal 0.0250 0.0354 0.5172 0.5173 379.0000
Negative skew -12.2178 -9.6873 0.0000 NaN 699.0000
Fat tailed -0.2336 -0.3232 0.3428 0.3452 366.0000
Frequent gains / rare losses 0.7222 0.7373 0.8639 0.5781 19.0000
AAPL 1.0018 1.4810 0.9994 0.9983 1225.0000

9. Probability across annualized thresholds

A threshold is the hurdle ratio, not a return target. threshold=0 asks whether the population ratio is positive; threshold=1 asks whether it exceeds an annualized ratio of 1. Raising the hurdle must lower the probability.

When reading either curve, inspect five features:

  1. Height at a chosen threshold. A value of 90% at threshold 1 means the estimated probability is 90% that the population ratio exceeds an annualized ratio of 1.
  2. Slope. A slowly declining curve indicates that the conclusion remains relatively robust as the hurdle rises. A steep curve means the result is sensitive to the selected threshold.
  3. Intersection with the 95% line. This approximates the largest ratio threshold supported with 95% confidence. It is often more informative than reporting only the observed ratio.
  4. Observed-ratio line. At a threshold equal to the observed ratio, probability is 50% because the sampling distribution is centered on the estimate. This is expected, not a warning sign.
  5. Value at threshold 0. This is the evidence that the population risk-adjusted ratio is positive. A high value at zero does not imply similarly strong evidence that the ratio exceeds 1 or 2.

Use q.plot.psr and q.plot.psor for standalone charts. Both X-axes are annualized, matching the public statistics APIs and the report cards.

thresholds = pd.Index([0.0, 0.5, 1.0, 1.5, 2.0], name="Annualized ratio threshold")
threshold_probabilities = pd.DataFrame(
    {
        "PSR": [
            q.stats.probabilistic_ratio(
                returns, threshold=value, periods_per_year=periods_per_year
            )
            for value in thresholds
        ],
        "PSoR": [
            q.stats.probabilistic_sortino_ratio(
                returns, threshold=value, periods_per_year=periods_per_year
            )
            for value in thresholds
        ],
    },
    index=thresholds,
)
assert threshold_probabilities.diff().iloc[1:].le(0.0).all().all()
threshold_probabilities.style.format("{:.2%}")
  PSR PSoR
Annualized ratio threshold    
0.000000 99.94% 99.83%
0.500000 94.82% 97.37%
1.000000 50.24% 82.90%
1.500000 5.31% 48.50%
2.000000 0.06% 15.26%
psr_figure = q.plot.psr(
    returns,
    periods_per_year=periods_per_year,
    threshold_max=3.0,
    title="AAPL: probability that Sharpe exceeds threshold",
)
psr_figure.show()

Reading the PSR curve

Start at a Sharpe threshold that represents the strategy’s intended use. Threshold 0 tests whether risk-adjusted performance is positive; threshold 1 is a more demanding quality hurdle.

For this graph, observe:

  • how far right the blue curve remains above the 95% guide;
  • whether probability collapses quickly as the threshold rises; and
  • the distance between threshold 0 and the observed Sharpe line.

A high PSR @ Th = 0 with a rapid decline before threshold 1 means there is strong evidence of a positive Sharpe, but much weaker evidence of an excellent Sharpe. A flatter, right-shifted curve supports stronger thresholds. Two strategies can have the same observed Sharpe but different PSR curves because sample size, skewness, and kurtosis affect the uncertainty of the estimate.

psor_figure = q.plot.psor(
    returns,
    periods_per_year=periods_per_year,
    threshold_max=3.0,
    title="AAPL: probability that Sortino exceeds threshold",
)
psor_figure.show()

Reading the PSoR curve and comparing both views

Read the orange PSoR curve in the same way, but remember that its uncertainty is driven by the negative excess-return observations. A small downside sample or strongly skewed losses can make PSoR less decisive even when the observed Sortino ratio is high.

Comparing the two curves can reveal the strategy’s risk shape:

  • PSoR below PSR: downside performance is less firmly supported, often because there are few losses or those losses are strongly skewed.
  • PSoR above PSR: much of the total variability may be positive variation, which Sharpe penalizes but Sortino does not.
  • Similar curves: total-volatility and downside-risk views provide broadly consistent evidence.

When comparing strategies, do not rank them only by the probability at threshold 0. Compare probabilities at economically meaningful thresholds and inspect curve shape. A curve that stays higher farther to the right provides stronger evidence across demanding hurdles.

These are in-sample confidence statements, not forecasts. They do not correct for testing many strategies, choosing the best result after inspection, regime changes, costs, or dependent observations. Out-of-sample validation and selection-bias controls remain necessary.

10. Cards must name their threshold

A probability without its benchmark is ambiguous. QRT’s backtest report computes both headline probabilities at the annualized ratio threshold 0, so the cards are labeled exactly PSR @ Th = 0 and PSoR @ Th = 0. That means “probability the population ratio is positive.”

When building custom cards, keep each threshold and label in the same configuration object. Changing the threshold then updates both the calculation and visible title.

card_thresholds = {"PSR": 0.0, "PSoR": 0.0}
card_values = pd.Series(
    {
        f"PSR @ Th = {card_thresholds['PSR']:g}": q.stats.probabilistic_ratio(
            returns,
            threshold=card_thresholds["PSR"],
            periods_per_year=periods_per_year,
        ),
        f"PSoR @ Th = {card_thresholds['PSoR']:g}": q.stats.probabilistic_sortino_ratio(
            returns,
            threshold=card_thresholds["PSoR"],
            periods_per_year=periods_per_year,
        ),
    },
    name="Probability",
)
assert list(card_values.index) == ["PSR @ Th = 0", "PSoR @ Th = 0"]
card_values.to_frame().T.style.format("{:.2%}")
  PSR @ Th = 0 PSoR @ Th = 0
Probability 99.94% 99.83%
alternative_thresholds = {"PSR": 1.0, "PSoR": 1.0}
alternative_cards = pd.Series(
    {
        f"PSR @ Th = {alternative_thresholds['PSR']:g}": q.stats.probabilistic_ratio(
            returns,
            threshold=alternative_thresholds["PSR"],
            periods_per_year=periods_per_year,
        ),
        f"PSoR @ Th = {alternative_thresholds['PSoR']:g}": q.stats.probabilistic_sortino_ratio(
            returns,
            threshold=alternative_thresholds["PSoR"],
            periods_per_year=periods_per_year,
        ),
    },
    name="Probability",
)
assert (alternative_cards.to_numpy() <= card_values.to_numpy()).all()
alternative_cards.to_frame().T.style.format("{:.2%}")
  PSR @ Th = 1 PSoR @ Th = 1
Probability 50.24% 82.90%

11. Edge cases and numerical stability

QRT drops missing values, rejects empty/non-numeric/infinite inputs, and rejects simple returns below -100%. Sharpe and Sortino are undefined (NaN) when their denominator is zero. PSR also needs at least two valid observations and a positive finite variance estimate. PSoR is deliberately stricter: it needs at least three negative excess-return observations.

The table below records outcomes without hiding exceptions. A finite probability must remain in \([0,1]\); NaN means the estimate is undefined rather than zero confidence.

def evaluate_probability(function, series):
    try:
        value = function(series, periods_per_year=periods_per_year)
        if np.isfinite(value):
            assert 0.0 <= value <= 1.0
        return value
    except (TypeError, ValueError) as error:
        return f"{type(error).__name__}: {error}"

edge_cases = {
    "Empty": pd.Series([], dtype=float),
    "One observation": pd.Series([0.01]),
    "Constant": pd.Series([0.01] * 10),
    "No downside": pd.Series([0.01, 0.02, 0.005, 0.015]),
    "Two downside observations": pd.Series([-0.01, -0.02, 0.03, 0.04]),
    "Missing values": pd.Series([0.01, np.nan, -0.02, 0.015, -0.005]),
    "Infinite value": pd.Series([0.01, np.inf, -0.02]),
    "Extreme negative skew": pd.Series([0.002] * 99 + [-0.25]),
}

edge_case_results = pd.DataFrame(
    {
        name: {
            "PSR": evaluate_probability(q.stats.probabilistic_ratio, series),
            "PSoR": evaluate_probability(q.stats.probabilistic_sortino_ratio, series),
        }
        for name, series in edge_cases.items()
    }
).T
edge_case_results
PSR PSoR
Empty ValueError: returns must contain at least one ... ValueError: returns must contain at least one ...
One observation NaN NaN
Constant 1.0000 NaN
No downside 0.9944 NaN
Two downside observations 0.7306 NaN
Missing values 0.5000 NaN
Infinite value ValueError: returns must not contain infinite ... ValueError: returns must not contain infinite ...
Extreme negative skew 0.4095 NaN
Back to top