Code / src/anomaly_atlas/stats/bootstrap.py
src/anomaly_atlas/stats/bootstrap.py
59 lines
# =============================================================================
# Project : anomaly-atlas
# File : src/anomaly_atlas/stats/bootstrap.py
# Purpose : Moving-block bootstrap and percentile confidence intervals
# Author : Simon-Pierre Boucher
# Contact : contact@spboucher.ai
# Data src : hfmarketdata.io (sole data source)
# Created : 2026-08-12
# Modified : 2026-08-12
# Platform : macOS / Apple Silicon (arm64)
# License : All rights reserved (research code)
# =============================================================================
"""Moving-block bootstrap for serially dependent data (Künsch 1989).
Blocks preserve short-range dependence, so statistics like AC1 or variance
ratios get honest sampling distributions. Every call takes an explicit seed.
"""
from __future__ import annotations
from collections.abc import Callable
import numpy as np
def moving_block_bootstrap(
x: np.ndarray,
stat: Callable[[np.ndarray], float],
block: int,
n_boot: int = 500,
seed: int = 0,
) -> np.ndarray:
"""Bootstrap distribution of `stat` using moving blocks of length `block`."""
x = np.asarray(x, dtype=float)
n = len(x)
if n < 2 * block:
return np.array([])
rng = np.random.default_rng(seed)
n_blocks = int(np.ceil(n / block))
starts_max = n - block + 1
out = np.empty(n_boot)
for i in range(n_boot):
starts = rng.integers(0, starts_max, n_blocks)
sample = np.concatenate([x[s : s + block] for s in starts])[:n]
out[i] = stat(sample)
return out
def percentile_ci(samples: np.ndarray, alpha: float = 0.05) -> tuple[float, float]:
"""Two-sided percentile confidence interval."""
s = np.asarray(samples, dtype=float)
s = s[np.isfinite(s)]
if len(s) == 0:
return (float("nan"), float("nan"))
return (
float(np.percentile(s, 100 * alpha / 2)),
float(np.percentile(s, 100 * (1 - alpha / 2))),
)