Source code for fynance.research.bootstrap

#!/usr/bin/env python
# -*- coding: utf-8 -*-

""" Block / stationary bootstrap for dependent (autocorrelated) return series.

:func:`~fynance.research.guards.permutation_test` builds its null by shuffling
returns **i.i.d.**, which destroys *all* temporal structure -- a fine null for
"is this edge just from the marginal distribution of returns", but too harsh a
null when the returns themselves are genuinely autocorrelated (e.g. slow
regimes, volatility clustering): an i.i.d. shuffle would flag that
autocorrelation itself as an artifact.

This module resamples **blocks** of returns instead of individual observations,
so short-range dependence survives the resampling:

- :func:`resample_paths` -- the resampling primitive (fixed-length ``circular``
  blocks, or ``stationary`` blocks with a random, geometrically-distributed
  length -- Politis & Romano, *The Stationary Bootstrap*, JASA 1994).
- :func:`bootstrap_metric` -- a percentile confidence interval for any metric,
  built from :func:`resample_paths`.
- :func:`block_permutation_test` -- the dependence-preserving analogue of
  :func:`~fynance.research.guards.permutation_test`: is the observed mean return
  larger than a block-bootstrap null centered at zero, i.e. is there a genuine
  edge once autocorrelation is accounted for.

All functions are data-agnostic and Numba-accelerated; nothing here reads real
data or stores results.

"""

# Built-in
from __future__ import annotations

from typing import Any, Callable

# Third-party
import numpy as np
from numba import njit
from numpy.typing import NDArray

__all__ = ['resample_paths', 'bootstrap_metric', 'block_permutation_test']


def _to_array(data: Any) -> NDArray[np.float64]:
    """ Coerce an array-like of returns to a 1-D float64 array. """
    return np.asarray(data, dtype=np.float64).reshape(-1)


# --------------------------------------------------------------------------- #
#   numba kernels                                                              #
# --------------------------------------------------------------------------- #


@njit(cache=True)
def _circular_kernel(returns, starts, block, T):
    """ Concatenate fixed-length, wrap-around blocks starting at ``starts``. """
    n_paths, n_blocks = starts.shape
    out = np.empty((n_paths, T), dtype=np.float64)
    for p in range(n_paths):
        pos = 0
        for b in range(n_blocks):
            s = starts[p, b]
            for j in range(block):
                if pos >= T:
                    break
                out[p, pos] = returns[(s + j) % T]
                pos += 1
    return out


@njit(cache=True)
def _stationary_kernel(returns, starts, u, p, T):
    """ Politis & Romano (1994) stationary bootstrap.

    At each step, with probability ``p`` a fresh (uniformly drawn) block start
    is used; otherwise the previous index is continued (wrapping circularly),
    so realized block lengths are geometrically distributed with mean ``1/p``.

    """
    n_paths = starts.shape[0]
    out = np.empty((n_paths, T), dtype=np.float64)
    for k in range(n_paths):
        idx = starts[k, 0]
        out[k, 0] = returns[idx]
        for t in range(1, T):
            if u[k, t] < p:
                idx = starts[k, t]
            else:
                idx = (idx + 1) % T
            out[k, t] = returns[idx]
    return out


# --------------------------------------------------------------------------- #
#   public API                                                                 #
# --------------------------------------------------------------------------- #


[docs] def resample_paths( returns: Any, *, n_paths: int = 1000, block: int = 21, method: str = "stationary", seed: int = 0, ) -> NDArray[np.float64]: """ Block-bootstrap resampled paths of a (possibly autocorrelated) series. Unlike an i.i.d. shuffle, resampling whole blocks preserves the short-range dependence *within* a block (e.g. volatility clustering, slow trends), which makes the resampled paths a more honest stand-in for "another draw from the same data-generating process" than a fully shuffled series. Two block schemes are supported: - ``'circular'``: fixed-length blocks of ``block`` observations, each starting at a uniformly-drawn index and wrapping circularly around the series (Politis & Romano, 1992). ``ceil(T / block)`` blocks are drawn and concatenated, then truncated to length ``T``. - ``'stationary'``: blocks of *random*, geometrically-distributed length with mean ``block`` (Politis & Romano, 1994): at each step, with probability ``1 / block`` a new block starts at a fresh uniformly-drawn index, otherwise the current block continues (wrapping circularly). This scheme yields a strictly stationary resampled process, unlike the fixed-length circular scheme (which has a seam at every block boundary). All randomness (block starts, and for ``'stationary'`` the per-step continue/restart draws) is generated once via ``numpy.random.default_rng(seed)`` *before* entering the Numba kernel, so the result is fully reproducible for a fixed ``seed``. Parameters ---------- returns : array-like Return series to resample, shape ``(T,)``. n_paths : int Number of resampled paths to draw. block : int Block length (``'circular'``) or mean block length (``'stationary'``). method : {'circular', 'stationary'} Resampling scheme, see above. seed : int Seed for reproducibility. Returns ------- numpy.ndarray, shape (n_paths, T) Resampled paths (each row is one bootstrap replicate of ``returns``). Raises ------ ValueError If ``returns`` has fewer than 2 observations, ``n_paths`` or ``block`` is not a positive integer, or ``method`` is not recognized. Examples -------- >>> import numpy as np >>> from fynance.research import resample_paths >>> r = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) >>> paths = resample_paths(r, n_paths=3, block=2, method='circular', seed=0) >>> paths.shape (3, 5) >>> a = resample_paths(r, n_paths=2, seed=1) >>> b = resample_paths(r, n_paths=2, seed=1) >>> bool(np.array_equal(a, b)) True """ r = _to_array(returns) T = r.size if T < 2: raise ValueError(f"returns must have at least 2 observations, got {T}") if n_paths < 1: raise ValueError(f"n_paths must be a positive integer, got {n_paths}") if block < 1: raise ValueError(f"block must be a positive integer, got {block}") rng = np.random.default_rng(seed) if method == "circular": n_blocks = -(-T // block) # ceil division starts = rng.integers(0, T, size=(n_paths, n_blocks)) return _circular_kernel(r, starts, block, T) if method == "stationary": starts = rng.integers(0, T, size=(n_paths, T)) u = rng.random((n_paths, T)) p = 1.0 / block return _stationary_kernel(r, starts, u, p, T) raise ValueError(f"method must be 'circular' or 'stationary', got {method!r}")
[docs] def bootstrap_metric( returns: Any, metric: Callable[[NDArray[np.float64]], float], *, n_paths: int = 1000, block: int = 21, method: str = "stationary", ci: float = 0.95, seed: int = 0, ) -> dict[str, Any]: """ Block-bootstrap percentile confidence interval for an arbitrary metric. Computes ``metric`` on the observed ``returns``, then again on ``n_paths`` block-bootstrap replicates from :func:`resample_paths`, and reports the percentile confidence interval of the replicate distribution. Parameters ---------- returns : array-like Return series, shape ``(T,)``. metric : callable ``returns -> float``, e.g. ``numpy.mean`` or a custom Sharpe-like statistic. Applied identically to the observed series and to every resampled path. n_paths : int Number of bootstrap replicates. block : int Block length (``'circular'``) or mean block length (``'stationary'``), forwarded to :func:`resample_paths`. method : {'circular', 'stationary'} Resampling scheme, forwarded to :func:`resample_paths`. ci : float Confidence level in ``(0, 1)``, e.g. ``0.95`` for a 95% CI. seed : int Seed for reproducibility, forwarded to :func:`resample_paths`. Returns ------- dict ``estimate`` (metric on the observed data), ``lo``/``hi`` (percentile CI bounds), ``distribution`` (the ``(n_paths,)`` array of per-replicate metric values). Raises ------ ValueError If ``ci`` is not in ``(0, 1)`` (other invalid inputs are caught by :func:`resample_paths`). Examples -------- >>> import numpy as np >>> from fynance.research import bootstrap_metric >>> r = np.array([0.01, -0.02, 0.015, 0.005, -0.01, 0.02, 0.0, 0.01]) >>> out = bootstrap_metric(r, np.mean, n_paths=500, block=2, seed=0) >>> sorted(out) ['distribution', 'estimate', 'hi', 'lo'] >>> round(out['estimate'], 5) 0.00375 >>> out['distribution'].shape (500,) >>> out['lo'] <= out['hi'] True """ if not (0.0 < ci < 1.0): raise ValueError(f"ci must be in (0, 1), got {ci}") r = _to_array(returns) estimate = float(metric(r)) paths = resample_paths(r, n_paths=n_paths, block=block, method=method, seed=seed) distribution = np.array( [metric(paths[i]) for i in range(paths.shape[0])], dtype=np.float64 ) alpha = 1.0 - ci lo, hi = np.quantile(distribution, [alpha / 2.0, 1.0 - alpha / 2.0]) return { "estimate": estimate, "lo": float(lo), "hi": float(hi), "distribution": distribution, }
[docs] def block_permutation_test( strategy_returns: Any, *, n_perm: int = 1000, block: int = 21, method: str = "stationary", seed: int = 0, ) -> float: """ Dependence-preserving analogue of :func:`~fynance.research.guards.permutation_test`. ``guards.permutation_test`` reruns a :class:`~fynance.strategy.Strategy` on i.i.d.-shuffled price paths -- an appropriate null when the question is "does this strategy exploit *any* real structure", but it also destroys genuine autocorrelation, which would bias the null toward rejecting a real, dependence-driven edge as noise. ``block_permutation_test`` instead takes an already-computed strategy **return series** (no strategy rerun) and tests whether its mean is significantly positive against a null that *preserves* autocorrelation: the observed series is demeaned, then block-bootstrap resampled via :func:`resample_paths` (so within-block dependence survives), giving the distribution of the mean statistic expected **under no drift**. The p-value uses the same smoothed convention as :func:`~fynance.research.guards.permutation_test`: ``(#{null >= observed} + 1) / (n_perm + 1)``. Parameters ---------- strategy_returns : array-like Realized return series of the strategy under test, shape ``(T,)``. n_perm : int Number of block-bootstrap replicates forming the null distribution. block : int Block length (``'circular'``) or mean block length (``'stationary'``), forwarded to :func:`resample_paths`. method : {'circular', 'stationary'} Resampling scheme, forwarded to :func:`resample_paths`. seed : int Master seed, forwarded to :func:`resample_paths` (reproducible for a fixed seed). Returns ------- float Smoothed p-value for the one-sided test "the observed mean return is larger than chance would produce, given the series' own autocorrelation structure". Low values indicate a genuine (not merely autocorrelation-driven) positive edge. Examples -------- >>> import numpy as np >>> from fynance.research import block_permutation_test >>> rng = np.random.default_rng(0) >>> noise = rng.standard_normal(300) * 0.01 >>> p = block_permutation_test(noise, n_perm=200, block=10, seed=1) >>> 0.0 < p <= 1.0 True """ r = _to_array(strategy_returns) observed = float(np.mean(r)) centered = r - observed null_paths = resample_paths( centered, n_paths=n_perm, block=block, method=method, seed=seed ) null = null_paths.mean(axis=1) return float((np.sum(null >= observed) + 1) / (n_perm + 1))