From 6d61eb6a887d55d2f7043ac525fdca12a3aaaadc Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Wed, 22 Jul 2026 08:14:33 -0400 Subject: [PATCH 1/3] fix: include finite-sample offset in MinTRL --- docs/user-guide/statistical-tests.md | 25 ++++++++---- .../evaluation/stats/minimum_track_record.py | 15 +++++--- .../backtest/statistical_validity.py | 38 +++++++++++-------- .../test_evaluation/fixtures/dsr_reference.py | 4 +- tests/test_evaluation/test_dsr_validation.py | 24 ++++++------ .../test_minimum_track_record.py | 33 ++++++++++++++++ tests/test_visualization/test_backtest.py | 9 +++++ 7 files changed, 107 insertions(+), 41 deletions(-) create mode 100644 tests/test_evaluation/test_minimum_track_record.py diff --git a/docs/user-guide/statistical-tests.md b/docs/user-guide/statistical-tests.md index 585c477..6869bfa 100644 --- a/docs/user-guide/statistical-tests.md +++ b/docs/user-guide/statistical-tests.md @@ -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: @@ -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", ) ``` diff --git a/src/ml4t/diagnostic/evaluation/stats/minimum_track_record.py b/src/ml4t/diagnostic/evaluation/stats/minimum_track_record.py index 648db2f..31fd3b2 100644 --- a/src/ml4t/diagnostic/evaluation/stats/minimum_track_record.py +++ b/src/ml4t/diagnostic/evaluation/stats/minimum_track_record.py @@ -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 @@ -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 @@ -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))) diff --git a/src/ml4t/diagnostic/visualization/backtest/statistical_validity.py b/src/ml4t/diagnostic/visualization/backtest/statistical_validity.py index 4a715d6..59bdf5c 100644 --- a/src/ml4t/diagnostic/visualization/backtest/statistical_validity.py +++ b/src/ml4t/diagnostic/visualization/backtest/statistical_validity.py @@ -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 @@ -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") @@ -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() diff --git a/tests/test_evaluation/fixtures/dsr_reference.py b/tests/test_evaluation/fixtures/dsr_reference.py index 5f3ac54..575b9ba 100644 --- a/tests/test_evaluation/fixtures/dsr_reference.py +++ b/tests/test_evaluation/fixtures/dsr_reference.py @@ -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, @@ -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, diff --git a/tests/test_evaluation/test_dsr_validation.py b/tests/test_evaluation/test_dsr_validation.py index a56706c..3f47dbf 100644 --- a/tests/test_evaluation/test_dsr_validation.py +++ b/tests/test_evaluation/test_dsr_validation.py @@ -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*) @@ -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 @@ -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, @@ -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, @@ -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, @@ -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, @@ -663,7 +665,7 @@ 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, @@ -671,7 +673,7 @@ def test_mintrl_alpha_sensitivity(self): 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, @@ -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, @@ -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, diff --git a/tests/test_evaluation/test_minimum_track_record.py b/tests/test_evaluation/test_minimum_track_record.py new file mode 100644 index 0000000..dad067d --- /dev/null +++ b/tests/test_evaluation/test_minimum_track_record.py @@ -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 diff --git a/tests/test_visualization/test_backtest.py b/tests/test_visualization/test_backtest.py index 477d376..64f5049 100644 --- a/tests/test_visualization/test_backtest.py +++ b/tests/test_visualization/test_backtest.py @@ -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( @@ -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) # ============================================================================= From 407f3b6576dba5160871b8ee139395c780fdc405 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Wed, 22 Jul 2026 12:53:41 -0400 Subject: [PATCH 2/3] chore: bump version to 0.1.0b24 --- src/ml4t/diagnostic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ml4t/diagnostic/__init__.py b/src/ml4t/diagnostic/__init__.py index aca0697..9cf3c15 100644 --- a/src/ml4t/diagnostic/__init__.py +++ b/src/ml4t/diagnostic/__init__.py @@ -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 ( From fde968dae26cda7b652dcf707be9896adf2d5c31 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Wed, 22 Jul 2026 12:55:00 -0400 Subject: [PATCH 3/3] ci: align Ruff release checks --- .pre-commit-config.yaml | 2 +- src/ml4t/diagnostic/integration/__init__.py | 1 + tests/test_integration/test_backtest_profile.py | 2 +- tests/test_integration/test_backtest_result.py | 4 ++-- tests/test_visualization/test_backtest.py | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 44c41b2..1cc9a62 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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/ diff --git a/src/ml4t/diagnostic/integration/__init__.py b/src/ml4t/diagnostic/integration/__init__.py index 4d62538..b37ea6c 100644 --- a/src/ml4t/diagnostic/integration/__init__.py +++ b/src/ml4t/diagnostic/integration/__init__.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: from ml4t.backtest import BacktestResult + from ml4t.diagnostic.evaluation import PortfolioAnalysis diff --git a/tests/test_integration/test_backtest_profile.py b/tests/test_integration/test_backtest_profile.py index e525857..b3bc4ce 100644 --- a/tests/test_integration/test_backtest_profile.py +++ b/tests/test_integration/test_backtest_profile.py @@ -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 diff --git a/tests/test_integration/test_backtest_result.py b/tests/test_integration/test_backtest_result.py index 4e9cbb9..e2f7f26 100644 --- a/tests/test_integration/test_backtest_result.py +++ b/tests/test_integration/test_backtest_result.py @@ -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, @@ -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: diff --git a/tests/test_visualization/test_backtest.py b/tests/test_visualization/test_backtest.py index 64f5049..c387946 100644 --- a/tests/test_visualization/test_backtest.py +++ b/tests/test_visualization/test_backtest.py @@ -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 # =============================================================================