Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,94 @@ def validate_window(input_data: Union["Series", "DataFrame"], window: int) -> No
raise ValueError(
f"Window '{window}' is greater than the input data length '{len(input_data)}'"
)


def deflated_sharpe_stats(
input_data: "Series",
trials: int,
rfr: float = 0.0,
trials_sr_std: Union[float, None] = None,
) -> dict:
"""Compute the deflated Sharpe ratio of a return series.

The deflated Sharpe ratio (Bailey & Lopez de Prado, 2014) is the
probability that the true Sharpe exceeds the expected maximum Sharpe of
`trials` zero-skill strategies — i.e. whether a result selected as the
best of `trials` attempts reflects skill rather than selection. Adjusts
for sample length and return skewness/kurtosis.

Parameters
----------
input_data : Series
Return series (per-period returns, not prices).
trials : int
Number of strategy variants tried before selecting this one.
rfr : float, optional
Per-period risk-free rate, by default 0.0.
trials_sr_std : float, optional
Standard deviation of the per-period Sharpe estimates across the
trials. Defaults to the null 1/sqrt(n-1) when unknown.

Returns
-------
dict
sharpe (per-period), expected_max_sharpe, deflated_sharpe_ratio,
observations, trials.

Raises
------
ValueError
If `trials` < 1 or the series has fewer than 3 observations or zero
variance.
"""
# pylint: disable=import-outside-toplevel
from numpy import asarray, e as np_e, finfo, sqrt
from scipy.stats import norm

if trials < 1:
raise ValueError("trials must be >= 1")
returns = asarray(input_data, dtype=float)
n = len(returns)
if n < 3:
raise ValueError("need at least 3 observations")
mu = returns.mean() - rfr
sd = returns.std() # population, consistent with the reference implementation
# `sd == 0` is exact and a constant series does not reach it: its standard
# deviation is floating-point residue rather than a true zero, so a flat 0.1%
# series has sd ~1e-19 and divides out to a Sharpe of ~1e16 -- finite, and past
# every isfinite guard after this one. Deflating that returned 1.0, i.e. certainty
# of a real edge, for the one input carrying no information about one. Compare
# against the resolution of a float at the scale of the data instead, so a
# genuinely low-volatility series still gets a number.
# The residue grows with the number of terms summed, so the floor is n eps rather
# than eps: measured at most 1.96 eps x scale over constant series spanning values
# 1e-7..1e3 and lengths 3..10000, while a real series with sigma=1e-12 sits more
# than ten orders of magnitude above n eps x scale.
if not sd > n * finfo(float).eps * abs(returns).max():
raise ValueError("zero-variance returns")
sr = mu / sd
skew_ = (((returns - returns.mean()) / sd) ** 3).mean()
kurt_ = (((returns - returns.mean()) / sd) ** 4).mean() # non-excess, normal = 3

if trials_sr_std is None:
trials_sr_std = 1.0 / sqrt(n - 1)
if trials == 1:
bar = 0.0
else:
euler = 0.5772156649015329
z1 = norm.ppf(1 - 1.0 / trials)
z2 = norm.ppf(1 - 1.0 / (trials * np_e))
bar = trials_sr_std * ((1 - euler) * z1 + euler * z2)

denom = 1 - skew_ * sr + (kurt_ - 1) / 4 * sr**2
if denom <= 0:
raise ValueError("degenerate return moments")
dsr = norm.cdf((sr - bar) * sqrt(n - 1) / sqrt(denom))

return {
"sharpe": float(sr),
"expected_max_sharpe": float(bar),
"deflated_sharpe_ratio": float(dsr),
"observations": n,
"trials": trials,
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ class OmegaModel(BaseModel):
omega: float


class DeflatedSharpeModel(BaseModel):
"""Deflated Sharpe ratio model (Bailey & Lopez de Prado, 2014)."""

sharpe: float
expected_max_sharpe: float
deflated_sharpe_ratio: float
observations: int
trials: int


class SummaryModel(BaseModel):
"""Summary model."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from openbb_core.app.router import Router
from openbb_core.provider.abstract.data import Data
from openbb_quantitative.models import (
DeflatedSharpeModel,
OmegaModel,
)
from pydantic import PositiveInt
Expand Down Expand Up @@ -269,3 +270,72 @@ def sortino_ratio(
results_ = df_to_basemodel(results)

return OBBject(results=results_)


@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get the Deflated Sharpe Ratio of a strategy selected as the best of 100 variants.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.performance.deflated_sharpe_ratio(data=returns, target="close", trials=100)',
],
),
APIEx(
parameters={
"target": "close",
"trials": 100,
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
},
),
],
)
def deflated_sharpe_ratio(
data: list[Data],
target: str,
trials: PositiveInt,
rfr: float = 0.0,
) -> OBBject[list[DeflatedSharpeModel]]:
"""Get the Deflated Sharpe Ratio.

The Deflated Sharpe Ratio (Bailey & Lopez de Prado, 2014) answers the question a raw Sharpe cannot: does this
result reflect skill, or is it what the luckiest of `trials` attempts would show by chance? When a strategy is
selected as the best of many tested variants, the expected maximum Sharpe under pure noise grows with the number
of trials — so the winning backtest must be scored against that bar, adjusting for sample length and the skewness
and kurtosis of returns. A value near 1 means the Sharpe survives the number of attempts made; a value near 0.5 or
below means the result is indistinguishable from selection luck. Essential whenever parameters were optimized or
multiple strategies compared before reporting a result.

Parameters
----------
data : list[Data]
Time series data of per-period returns.
target : str
Target column name.
trials : PositiveInt
Number of strategy variants tried before selecting this one.
rfr : float, optional
Per-period risk-free rate, by default 0.0

Returns
-------
OBBject[list[DeflatedSharpeModel]]
Sharpe, expected max Sharpe of `trials` zero-skill attempts, and the deflated Sharpe ratio.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
get_target_column,
)
from openbb_quantitative.helpers import deflated_sharpe_stats

df = basemodel_to_df(data)
series_target = get_target_column(df, target)
stats_ = deflated_sharpe_stats(series_target, trials=trials, rfr=rfr)

return OBBject(results=[DeflatedSharpeModel(**stats_)])
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Test the deflated Sharpe ratio helper."""

import math

import pandas as pd
import pytest
from extensions.quantitative.openbb_quantitative.helpers import (
deflated_sharpe_stats,
)

# Reference values computed with the numguard reference implementation of
# Bailey & Lopez de Prado (2014) on the same deterministic series
# (population moments, null trial dispersion 1/sqrt(n-1)).
RETURNS = pd.Series(
[0.003 + 0.01 * math.sin(i) + 0.004 * math.cos(3 * i) for i in range(90)]
)


def test_matches_reference_implementation():
"""Parity against the reference implementation."""
result = deflated_sharpe_stats(RETURNS, trials=50)
assert result["sharpe"] == pytest.approx(0.4101142526095976, abs=1e-9)
assert result["expected_max_sharpe"] == pytest.approx(
0.24128764537744862, abs=1e-9
)
assert result["deflated_sharpe_ratio"] == pytest.approx(
0.9399085246020616, abs=1e-7
)
assert result["observations"] == 90
assert result["trials"] == 50


def test_single_trial_has_no_deflation():
"""With one trial the bar is zero: pure probabilistic Sharpe vs 0."""
result = deflated_sharpe_stats(RETURNS, trials=1)
assert result["expected_max_sharpe"] == 0.0
assert result["deflated_sharpe_ratio"] == pytest.approx(
0.9999199954582253, abs=1e-7
)


def test_deflation_is_monotonic_in_trials():
"""More attempts must never make the same result more credible."""
values = [
deflated_sharpe_stats(RETURNS, trials=n)["deflated_sharpe_ratio"]
for n in (1, 10, 100, 1000)
]
assert all(values[i] > values[i + 1] for i in range(len(values) - 1))


def test_invalid_inputs_raise():
"""Trials and sample-size guards."""
with pytest.raises(ValueError):
deflated_sharpe_stats(RETURNS, trials=0)
with pytest.raises(ValueError):
deflated_sharpe_stats(RETURNS.head(2), trials=10)
with pytest.raises(ValueError):
deflated_sharpe_stats(pd.Series([0.01] * 10), trials=10)


def test_zero_dispersion_raises_across_values_and_lengths():
"""A constant series carries no information about an edge, at any scale.

`sd == 0` is exact and a constant series does not reach it: its standard
deviation is floating-point residue rather than a true zero, so the ratio came
out finite (~1e16) and reached the deflation arithmetic, which answered 1.0.
The residue depends on both the value and the length, so this is checked over a
grid: a guard calibrated on a single series passes while still leaking elsewhere.
"""
for value in (1e-7, 1e-4, 0.001, 0.01, 1.0, 100.0):
for n in (3, 10, 250, 5000):
with pytest.raises(ValueError, match="zero-variance"):
deflated_sharpe_stats(pd.Series([value] * n), trials=4)

# The guard is relative to the scale of the data: a real but very quiet series
# still gets a number.
import numpy as np

quiet = pd.Series(np.random.default_rng(1).normal(0, 1e-8, 250))
assert 0 <= deflated_sharpe_stats(quiet, trials=4)["deflated_sharpe_ratio"] <= 1