feat: add block-bootstrap backtest intervals
This commit is contained in:
@@ -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),
|
||||
}
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user