Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.10
rev: v0.13.2
hooks:
- id: ruff-format
exclude: ^examples/
Expand Down
25 changes: 17 additions & 8 deletions docs/user-guide/statistical-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,18 +89,24 @@ print(f"Complexity penalty: {result.complexity:.4f}")
Calculate how long a track record must be for statistical significance:

```python
import numpy as np

from ml4t.diagnostic.evaluation.stats import compute_min_trl

result = compute_min_trl(
sharpe_ratio=1.5,
target_pvalue=0.05,
frequency='daily'
observed_sharpe=1.5 / np.sqrt(252),
target_sharpe=0.5 / np.sqrt(252),
confidence_level=0.95,
frequency="daily",
)

print(f"Minimum observations: {result.min_observations}")
print(f"Minimum years: {result.min_years:.1f}")
print(f"Minimum observations: {result.min_trl:.0f}")
print(f"Minimum years: {result.min_trl_years:.1f}")
```

Sharpe inputs use the return series' native frequency. The example converts
annualized Sharpe ratios to daily values before requesting a daily MinTRL.

### MinTRL with Multiple Testing

For FWER-controlled significance across multiple strategies:
Expand All @@ -109,9 +115,12 @@ For FWER-controlled significance across multiple strategies:
from ml4t.diagnostic.evaluation.stats import min_trl_fwer

result = min_trl_fwer(
sharpe_ratio=1.5,
num_trials=50,
alpha=0.05
observed_sharpe=1.5 / np.sqrt(252),
n_trials=50,
variance_trials=0.04 / 252,
target_sharpe=0.5 / np.sqrt(252),
confidence_level=0.95,
frequency="daily",
)
```

Expand Down
2 changes: 1 addition & 1 deletion src/ml4t/diagnostic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
exported in __all__. Breaking changes will only occur in major version bumps.
"""

__version__ = "0.1.0b23"
__version__ = "0.1.0b24"

# Sub-modules for advanced usage
from . import (
Expand Down
15 changes: 10 additions & 5 deletions src/ml4t/diagnostic/evaluation/stats/minimum_track_record.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
"""Minimum Track Record Length (MinTRL) calculation.

MinTRL is the minimum number of observations required to reject the null
hypothesis (SR ≤ target) at the specified confidence level.
hypothesis (SR ≤ target) at the specified confidence level. The implementation
uses the finite-sample form, with the leading one-observation offset.

References
----------
Bailey, D. H., & López de Prado, M. (2012).
"The Sharpe Ratio Efficient Frontier." Journal of Risk, 15(2), 3-44.

López de Prado, M., Lipton, A., & Zoonekynd, V. (2025).
"How to Use the Sharpe Ratio." ADIA Lab Research Paper Series, No. 19.
Equation 11, page 9.
Equation 11 supplies the serial-correlation variance adjustment.
"""

from __future__ import annotations
Expand Down Expand Up @@ -150,7 +154,8 @@ def _compute_min_trl_core(
-------
float
Minimum number of observations. Returns math.inf if
observed SR <= target SR.
observed SR <= target SR. The finite-sample result is rounded up
after adding the leading one-observation offset.
"""
rho = autocorrelation
sr_diff = observed_sharpe - target_sharpe
Expand Down Expand Up @@ -178,9 +183,9 @@ def _compute_min_trl_core(
# Variance term (without 1/T factor)
var_term = a - b * skewness * target_sharpe + c * (kurtosis - 1) / 4 * target_sharpe**2

# MinTRL formula (Equation 11)
# Finite-sample MinTRL adds one observation to the asymptotic variance term.
try:
min_trl = var_term * (z_alpha / sr_diff) ** 2
min_trl = 1.0 + var_term * (z_alpha / sr_diff) ** 2
if np.isinf(min_trl):
return float("inf")
return float(np.ceil(max(min_trl, 1)))
Expand Down
1 change: 1 addition & 0 deletions src/ml4t/diagnostic/integration/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

if TYPE_CHECKING:
from ml4t.backtest import BacktestResult

from ml4t.diagnostic.evaluation import PortfolioAnalysis


Expand Down
38 changes: 23 additions & 15 deletions src/ml4t/diagnostic/visualization/backtest/statistical_validity.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import numpy as np
import plotly.graph_objects as go

from ml4t.diagnostic.evaluation.stats import compute_min_trl
from ml4t.diagnostic.visualization._colors import COLORS as _ML4T_COLORS
from ml4t.diagnostic.visualization.core import get_theme_config

Expand Down Expand Up @@ -598,26 +599,30 @@ def plot_minimum_track_record(

Notes
-----
The minimum track record length formula is:
MinTRL = 1 + (1 - γ₃*SR + γ₄*SR²/4) * (z_α / SR
The finite-sample minimum track record length formula is:
MinTRL = 1 + (1 - γ₃*SR + (γ₄-1)*SR²/4) * (z_α / (SR-SR₀)

where γ₃ is skewness, γ₄ is excess kurtosis, and z_α is the
critical value for confidence level α.
where γ₃ is skewness, γ₄ is Pearson kurtosis, and z_α is the
critical value for confidence level α. This visualization assumes
normally distributed, serially uncorrelated returns.
"""
from scipy import stats

theme_config = get_theme_config(theme)
colors = theme_config["colorway"]

# Calculate MinTRL (simplified, assuming normal returns)
# Calculate finite-sample MinTRL under the plot's normal-return assumption.
z_alpha = stats.norm.ppf(confidence)
sharpe_diff = observed_sharpe - sr_benchmark

if sharpe_diff <= 0:
min_trl = float("inf")
else:
# Simplified MinTRL (assuming γ₃=0, γ₄=3)
min_trl = (z_alpha / sharpe_diff) ** 2
annualization_factor = np.sqrt(periods_per_year)
observed_sharpe_period = observed_sharpe / annualization_factor
benchmark_sharpe_period = sr_benchmark / annualization_factor
min_trl_result = compute_min_trl(
observed_sharpe=observed_sharpe_period,
target_sharpe=benchmark_sharpe_period,
confidence_level=confidence,
periods_per_year=periods_per_year,
)
min_trl = min_trl_result.min_trl

# Convert to years
min_trl_years = min_trl / periods_per_year if min_trl != float("inf") else float("inf")
Expand All @@ -633,9 +638,12 @@ def plot_minimum_track_record(
# Generate data for the required SR curve at different track record lengths
periods_range = np.linspace(10, max_periods, 100)

# Required SR to achieve significance at each track record length
# SR_required = z_alpha / sqrt(T)
required_sr = z_alpha / np.sqrt(periods_range) + sr_benchmark
# Invert finite-sample MinTRL and return the required annualized Sharpe.
variance_adjustment = 1.0 + 0.5 * benchmark_sharpe_period**2
required_sr_period = benchmark_sharpe_period + z_alpha * np.sqrt(
variance_adjustment / (periods_range - 1.0)
)
required_sr = required_sr_period * annualization_factor

fig = go.Figure()

Expand Down
4 changes: 2 additions & 2 deletions tests/test_evaluation/fixtures/dsr_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ class PowerTestCase(NamedTuple):
tolerance=0.05, # Our calculation gives 27.063, paper says 27.109 (0.17% diff)
)

# Normal returns case - corrected expected value computed via minimum_track_record_length()
# Normal returns case - continuous asymptotic reference before finite-sample adjustment
MINTRL_NORMAL = MinTRLTestCase(
name="MinTRL with normal returns",
sharpe_ratio=1.0,
Expand All @@ -260,7 +260,7 @@ class PowerTestCase(NamedTuple):
tolerance=0.02,
)

# High target SR - corrected expected value computed via minimum_track_record_length()
# High target SR - continuous asymptotic reference before finite-sample adjustment
MINTRL_HIGH_TARGET = MinTRLTestCase(
name="MinTRL with high target SR",
sharpe_ratio=1.5,
Expand Down
24 changes: 13 additions & 11 deletions tests/test_evaluation/test_dsr_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,16 +548,18 @@ def test_psr_sample_size_effect(self):
# =============================================================================


def minimum_track_record_length(
def asymptotic_minimum_track_record_length(
sharpe_ratio: float,
sharpe_star: float,
skewness: float,
excess_kurtosis: float,
alpha: float = 0.05,
) -> float:
"""Calculate Minimum Track Record Length (MinTRL).
"""Reproduce the paper's continuous asymptotic MinTRL quantity.

Implementation based on equation (11) from López de Prado et al. (2025).
The production implementation adds the finite-sample leading offset and
rounds up to an integer observation count.

Args:
sharpe_ratio: Observed Sharpe ratio (SR_hat*)
Expand All @@ -582,7 +584,7 @@ def minimum_track_record_length(
# So (γ₄-1)/4 = (excess_kurtosis + 2)/4
variance_adjustment = 1 - skewness * sharpe_star + (excess_kurtosis + 2) / 4 * sharpe_star**2

# MinTRL = variance_adjustment * (z_alpha / (SR - SR_0))²
# Continuous asymptotic quantity before the finite-sample offset and rounding.
mintrl = variance_adjustment * (z_alpha / (sharpe_ratio - sharpe_star)) ** 2

return mintrl
Expand All @@ -600,7 +602,7 @@ def test_mintrl_reference(self, case):
Paper cases (SR_0=0 and SR_0=0.1) match within <0.2%.
Other cases use computed expected values for consistency validation.
"""
result = minimum_track_record_length(
result = asymptotic_minimum_track_record_length(
sharpe_ratio=case.sharpe_ratio,
sharpe_star=case.sharpe_star,
skewness=case.skewness,
Expand All @@ -620,7 +622,7 @@ def test_mintrl_reference(self, case):

def test_mintrl_zero_target(self):
"""Edge case: MinTRL with SR_0 = 0."""
mintrl = minimum_track_record_length(
mintrl = asymptotic_minimum_track_record_length(
sharpe_ratio=1.0,
sharpe_star=0.0,
skewness=0.0,
Expand All @@ -634,7 +636,7 @@ def test_mintrl_zero_target(self):

def test_mintrl_equal_sharpes(self):
"""Edge case: MinTRL when SR = SR_0."""
mintrl = minimum_track_record_length(
mintrl = asymptotic_minimum_track_record_length(
sharpe_ratio=1.0,
sharpe_star=1.0,
skewness=0.0,
Expand All @@ -647,7 +649,7 @@ def test_mintrl_equal_sharpes(self):

def test_mintrl_below_target(self):
"""Edge case: MinTRL when SR < SR_0."""
mintrl = minimum_track_record_length(
mintrl = asymptotic_minimum_track_record_length(
sharpe_ratio=0.5,
sharpe_star=1.0,
skewness=0.0,
Expand All @@ -663,15 +665,15 @@ def test_mintrl_alpha_sensitivity(self):
sharpe_ratio = 1.0
sharpe_star = 0.5

mintrl_lenient = minimum_track_record_length(
mintrl_lenient = asymptotic_minimum_track_record_length(
sharpe_ratio=sharpe_ratio,
sharpe_star=sharpe_star,
skewness=0.0,
excess_kurtosis=0.0,
alpha=0.10, # Lenient
)

mintrl_strict = minimum_track_record_length(
mintrl_strict = asymptotic_minimum_track_record_length(
sharpe_ratio=sharpe_ratio,
sharpe_star=sharpe_star,
skewness=0.0,
Expand Down Expand Up @@ -1380,7 +1382,7 @@ def test_non_normality_consistent_across_functions(self):
)

# Calculate MinTRL
mintrl = minimum_track_record_length(
mintrl = asymptotic_minimum_track_record_length(
sharpe_ratio=sharpe_ratio,
sharpe_star=sharpe_star,
skewness=skewness,
Expand All @@ -1400,7 +1402,7 @@ def test_non_normality_consistent_across_functions(self):
assert psr < psr_normal

# MinTRL should be higher with fat tails
mintrl_normal = minimum_track_record_length(
mintrl_normal = asymptotic_minimum_track_record_length(
sharpe_ratio=sharpe_ratio,
sharpe_star=0.0,
skewness=0.0,
Expand Down
33 changes: 33 additions & 0 deletions tests/test_evaluation/test_minimum_track_record.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Regression tests for Minimum Track Record Length calculations."""

import numpy as np

from ml4t.diagnostic.evaluation.stats import compute_min_trl


def test_min_trl_includes_finite_sample_offset_for_strategy_14() -> None:
"""Match the seeded Chapter 16 strategy-selection example."""
rng = np.random.default_rng(123)
n_strategies = 30
n_days = 504
true_sharpes = rng.permutation([0.8] * 5 + [0.0] * (n_strategies - 5))

strategy_returns: dict[str, np.ndarray] = {}
observed_sharpes: dict[str, float] = {}
for index, true_sharpe in enumerate(true_sharpes, start=1):
daily_volatility = 0.15 / np.sqrt(252)
daily_mean = true_sharpe * 0.15 / 252
returns = rng.normal(daily_mean, daily_volatility, n_days)
name = f"Strategy_{index}"
strategy_returns[name] = returns
observed_sharpes[name] = returns.mean() / returns.std(ddof=1) * np.sqrt(252)

best_name = max(observed_sharpes, key=observed_sharpes.__getitem__)
result = compute_min_trl(
returns=strategy_returns[best_name],
target_sharpe=0.5 / np.sqrt(252),
frequency="daily",
)

assert best_name == "Strategy_14"
assert result.min_trl == 213.0
2 changes: 1 addition & 1 deletion tests/test_integration/test_backtest_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

import polars as pl
import pytest

from ml4t.backtest import BacktestResult
from ml4t.backtest.types import Fill, OrderSide, Trade

from ml4t.diagnostic.integration import analyze_backtest_result


Expand Down
4 changes: 2 additions & 2 deletions tests/test_integration/test_backtest_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
import numpy as np
import polars as pl
import pytest

from ml4t.backtest import BacktestConfig, BacktestResult
from ml4t.backtest.types import Trade
from ml4t.specs import FeedSpec

from ml4t.diagnostic.integration import (
BacktestReportMetadata,
compute_metrics_from_result,
Expand All @@ -21,7 +22,6 @@
portfolio_analysis_from_result,
profile_from_run_artifacts,
)
from ml4t.specs import FeedSpec


def create_sample_result(n_trades: int = 20) -> BacktestResult:
Expand Down
11 changes: 10 additions & 1 deletion tests/test_visualization/test_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
import plotly.graph_objects as go
import polars as pl
import pytest

from ml4t.backtest import BacktestResult
from ml4t.backtest.types import Fill, OrderSide, Trade

from ml4t.diagnostic.integration import BacktestReportMetadata

# =============================================================================
Expand Down Expand Up @@ -620,6 +620,7 @@ def test_plot_ras_analysis(self):

def test_plot_minimum_track_record(self):
"""Test MinTRL visualization."""
from ml4t.diagnostic.evaluation.stats import compute_min_trl
from ml4t.diagnostic.visualization.backtest import plot_minimum_track_record

fig = plot_minimum_track_record(
Expand All @@ -629,6 +630,14 @@ def test_plot_minimum_track_record(self):
)

assert isinstance(fig, go.Figure)
expected = compute_min_trl(
observed_sharpe=1.8 / np.sqrt(252),
target_sharpe=0.5 / np.sqrt(252),
periods_per_year=252,
)
min_trl_line = fig.layout.shapes[0]
assert min_trl_line.x0 == pytest.approx(expected.min_trl_years)
assert min_trl_line.x1 == pytest.approx(expected.min_trl_years)


# =============================================================================
Expand Down