From 1f754ed85dd8d791ccfb90543b65d3bef9f790bd Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 26 Jul 2026 23:04:54 +0000 Subject: [PATCH] feat: add block-bootstrap backtest intervals --- backtesting/statistics.py | 85 +++++++++++++++++++++++++++++++ tests/test_backtest_statistics.py | 34 +++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 backtesting/statistics.py create mode 100644 tests/test_backtest_statistics.py diff --git a/backtesting/statistics.py b/backtesting/statistics.py new file mode 100644 index 0000000..8817fdb --- /dev/null +++ b/backtesting/statistics.py @@ -0,0 +1,85 @@ +"""Statistical helpers for honest time-series backtest reporting.""" + +from __future__ import annotations + +import math +import random +import statistics as stdlib_statistics +from collections.abc import Iterable + + +def _quantile(sorted_values: list[float], probability: float) -> float: + position = (len(sorted_values) - 1) * probability + lower = math.floor(position) + upper = math.ceil(position) + if lower == upper: + return sorted_values[lower] + fraction = position - lower + return sorted_values[lower] * (1 - fraction) + sorted_values[upper] * fraction + + +def moving_block_bootstrap_ci( + values: Iterable[float], + *, + block_size: int = 30, + n_resamples: int = 1_000, + confidence: float = 0.95, + seed: int = 42, +) -> dict[str, float | int]: + """Estimate a mean and CI while preserving local serial dependence.""" + series = [float(value) for value in values] + if not series: + raise ValueError("values must not be empty") + if block_size < 1 or block_size > len(series): + raise ValueError("block_size must be between 1 and the number of values") + if n_resamples < 2: + raise ValueError("n_resamples must be at least 2") + if not 0 < confidence < 1: + raise ValueError("confidence must be between 0 and 1") + + rng = random.Random(seed) + sample_means: list[float] = [] + final_start = len(series) - block_size + for _ in range(n_resamples): + sample: list[float] = [] + while len(sample) < len(series): + start = rng.randint(0, final_start) + sample.extend(series[start:start + block_size]) + sample = sample[:len(series)] + sample_means.append(sum(sample) / len(sample)) + + sample_means.sort() + tail = (1 - confidence) / 2 + return { + "estimate": sum(series) / len(series), + "ci_low": _quantile(sample_means, tail), + "ci_high": _quantile(sample_means, 1 - tail), + "n": len(series), + } + + +def summarize_returns( + values: Iterable[float], + *, + block_size: int = 30, + n_resamples: int = 1_000, + confidence: float = 0.95, + seed: int = 42, +) -> dict[str, float | int]: + """Summarize realized returns with an autocorrelation-aware mean CI.""" + series = [float(value) for value in values] + interval = moving_block_bootstrap_ci( + series, + block_size=min(block_size, len(series)), + n_resamples=n_resamples, + confidence=confidence, + seed=seed, + ) + return { + "n": len(series), + "mean": round(float(interval["estimate"]), 2), + "median": round(stdlib_statistics.median(series), 2), + "win_rate": round(sum(value > 0 for value in series) / len(series) * 100, 1), + "mean_ci_low": round(float(interval["ci_low"]), 2), + "mean_ci_high": round(float(interval["ci_high"]), 2), + } diff --git a/tests/test_backtest_statistics.py b/tests/test_backtest_statistics.py new file mode 100644 index 0000000..7840342 --- /dev/null +++ b/tests/test_backtest_statistics.py @@ -0,0 +1,34 @@ +from backtesting import statistics + + +def test_moving_block_bootstrap_is_deterministic_and_handles_constant_series(): + first = statistics.moving_block_bootstrap_ci( + [12.5] * 120, + block_size=15, + n_resamples=200, + seed=7, + ) + second = statistics.moving_block_bootstrap_ci( + [12.5] * 120, + block_size=15, + n_resamples=200, + seed=7, + ) + + assert first == second + assert first == {"estimate": 12.5, "ci_low": 12.5, "ci_high": 12.5, "n": 120} + + +def test_summarize_returns_reports_observations_and_block_bootstrap_interval(): + summary = statistics.summarize_returns( + [10.0, -5.0, 20.0, -10.0], + block_size=2, + n_resamples=200, + seed=3, + ) + + assert summary["n"] == 4 + assert summary["mean"] == 3.75 + assert summary["median"] == 2.5 + assert summary["win_rate"] == 50.0 + assert summary["mean_ci_low"] <= summary["mean"] <= summary["mean_ci_high"]