Source code for trader.replay.bootstrap

"""A date-clustered block bootstrap for the edge's confidence interval.

Rows on one decision date share a market factor — nine symbols all traded on
the same news day are not nine independent observations — so this resamples
*dates*, not rows. A per-row t-test would treat correlated rows as
independent and understate the interval.
"""

import random
from collections import defaultdict
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from datetime import date

from trader.replay.scoring import ScoredRow

__all__ = ["Interval", "date_clustered_interval"]


[docs] @dataclass(frozen=True, slots=True) class Interval: """A percentile interval from a block bootstrap, with its own provenance.""" low: float | None high: float | None point: float | None draws: int seed: int resamples_used: int
[docs] def date_clustered_interval( rows: Sequence[ScoredRow], statistic: Callable[[Sequence[ScoredRow]], float | None], *, draws: int = 10_000, seed: int = 7, confidence: float = 0.95, ) -> Interval: """Resample decision dates with replacement and interval `statistic`. Each draw picks as many dates (with replacement) as the row set has distinct dates, and takes every row on each picked date — so a draw with two dates of three rows each always has six rows, never four: the unit being resampled is the date, not the row. A draw whose `statistic` returns `None` (e.g. no acting rows in that resample) is skipped rather than counted as zero, which would drag the interval toward zero and understate the uncertainty. `resamples_used` reports how many draws actually contributed, so a degenerate sample is visible rather than silently averaged away. """ by_date: dict[date, list[ScoredRow]] = defaultdict(list) for row in rows: by_date[row.decision_at.date()].append(row) dates = list(by_date.keys()) point = statistic(rows) rng = random.Random(seed) # noqa: S311 - a statistical bootstrap, not a security context samples: list[float] = [] for _ in range(draws): if not dates: break resample_rows: list[ScoredRow] = [] for _ in range(len(dates)): picked = dates[rng.randrange(len(dates))] resample_rows.extend(by_date[picked]) value = statistic(resample_rows) if value is not None: samples.append(value) samples.sort() resamples_used = len(samples) if resamples_used == 0: return Interval(None, None, point, draws, seed, 0) alpha = (1 - confidence) / 2 low_index = min(resamples_used - 1, max(0, int(alpha * resamples_used))) high_index = min( resamples_used - 1, max(low_index, int((1 - alpha) * resamples_used) - 1) ) return Interval( low=samples[low_index], high=samples[high_index], point=point, draws=draws, seed=seed, resamples_used=resamples_used, )