diff --git a/openbb_platform/extensions/quantitative/openbb_quantitative/helpers.py b/openbb_platform/extensions/quantitative/openbb_quantitative/helpers.py index 1ecb1e8693e5..ff478d40366d 100644 --- a/openbb_platform/extensions/quantitative/openbb_quantitative/helpers.py +++ b/openbb_platform/extensions/quantitative/openbb_quantitative/helpers.py @@ -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, + } diff --git a/openbb_platform/extensions/quantitative/openbb_quantitative/models.py b/openbb_platform/extensions/quantitative/openbb_quantitative/models.py index 5082739d3159..f0e4782bebb7 100644 --- a/openbb_platform/extensions/quantitative/openbb_quantitative/models.py +++ b/openbb_platform/extensions/quantitative/openbb_quantitative/models.py @@ -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.""" diff --git a/openbb_platform/extensions/quantitative/openbb_quantitative/performance/performance_router.py b/openbb_platform/extensions/quantitative/openbb_quantitative/performance/performance_router.py index d2659c3f14b1..9ec55694d0b4 100644 --- a/openbb_platform/extensions/quantitative/openbb_quantitative/performance/performance_router.py +++ b/openbb_platform/extensions/quantitative/openbb_quantitative/performance/performance_router.py @@ -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 @@ -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_)]) diff --git a/openbb_platform/extensions/quantitative/tests/test_deflated_sharpe.py b/openbb_platform/extensions/quantitative/tests/test_deflated_sharpe.py new file mode 100644 index 000000000000..d81bf1c4a864 --- /dev/null +++ b/openbb_platform/extensions/quantitative/tests/test_deflated_sharpe.py @@ -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