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