| """Monte-Carlo permutation test for trading rules. |
| |
| The question this answers is not "did the strategy make money" but "would a |
| rule of this shape have made this much money on a market with no exploitable |
| structure?". We destroy the serial dependence in the price path while keeping |
| its distribution of moves intact, re-run the *same* strategy on each shuffled |
| market, and see where the real result lands in that null distribution. |
| |
| A strategy whose Sharpe sits comfortably inside the null is not a strategy — |
| it is a lottery ticket that happened to win. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from typing import Callable, Optional |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| from ..engine import bars_to_returns, run_backtest |
| from ..types import CostModel |
|
|
| __all__ = ["permutation_test", "PermutationResult", "permute_bars"] |
|
|
|
|
| @dataclass |
| class PermutationResult: |
| observed: float |
| null: np.ndarray |
| p_value: float |
| method: str |
| n_permutations: int |
|
|
| @property |
| def null_mean(self) -> float: |
| return float(np.mean(self.null)) if self.null.size else 0.0 |
|
|
| @property |
| def percentile(self) -> float: |
| """Where the observed Sharpe sits in the null distribution, 0-100.""" |
| if not self.null.size: |
| return 50.0 |
| return float((self.null < self.observed).mean() * 100.0) |
|
|
|
|
| def _decompose(df: pd.DataFrame) -> tuple[np.ndarray, float]: |
| """Split bars into scale-free log moves that can be reshuffled safely.""" |
| open_ = df["open"].to_numpy(dtype=float) |
| high = df["high"].to_numpy(dtype=float) |
| low = df["low"].to_numpy(dtype=float) |
| close = df["close"].to_numpy(dtype=float) |
| volume = df["volume"].to_numpy(dtype=float) |
|
|
| gap = np.log(open_[1:] / close[:-1]) |
| hi = np.log(np.maximum(high[1:], open_[1:]) / open_[1:]) |
| lo = np.log(np.minimum(low[1:], open_[1:]) / open_[1:]) |
| body = np.log(close[1:] / open_[1:]) |
| return np.column_stack([gap, hi, lo, body, volume[1:]]), float(close[0]) |
|
|
|
|
| def _rebuild(parts: np.ndarray, anchor: float, index: pd.Index, first_row: pd.Series) -> pd.DataFrame: |
| gap, hi, lo, body, volume = (parts[:, i] for i in range(5)) |
| n = parts.shape[0] + 1 |
|
|
| close = np.empty(n) |
| open_ = np.empty(n) |
| high = np.empty(n) |
| low = np.empty(n) |
| vol = np.empty(n) |
|
|
| close[0] = anchor |
| open_[0] = float(first_row["open"]) |
| high[0] = float(first_row["high"]) |
| low[0] = float(first_row["low"]) |
| vol[0] = float(first_row["volume"]) |
|
|
| |
| close[1:] = anchor * np.exp(np.cumsum(gap + body)) |
| open_[1:] = close[:-1] * np.exp(gap) |
| high[1:] = open_[1:] * np.exp(hi) |
| low[1:] = open_[1:] * np.exp(lo) |
| vol[1:] = volume |
|
|
| return pd.DataFrame( |
| {"open": open_, "high": high, "low": low, "close": close, "volume": vol}, index=index |
| ) |
|
|
|
|
| def permute_bars( |
| df: pd.DataFrame, |
| rng: np.random.Generator, |
| method: str = "permute", |
| block: int = 20, |
| ) -> pd.DataFrame: |
| """Return a shuffled market with the same index and bar anatomy. |
| |
| ``permute`` reshuffles individual bars, destroying all serial structure. |
| ``block`` resamples contiguous blocks with replacement, which preserves |
| short-horizon autocorrelation and volatility clustering — a harder null |
| that trend strategies deserve to be tested against. |
| """ |
| parts, anchor = _decompose(df) |
| m = parts.shape[0] |
| if m < 2: |
| return df.copy() |
|
|
| if method == "block": |
| size = max(2, min(int(block), m)) |
| starts = rng.integers(0, m, size=int(np.ceil(m / size))) |
| order = np.concatenate([(np.arange(s, s + size) % m) for s in starts])[:m] |
| else: |
| order = rng.permutation(m) |
|
|
| return _rebuild(parts[order], anchor, df.index, df.iloc[0]) |
|
|
|
|
| def permutation_test( |
| df: pd.DataFrame, |
| signal_fn: Callable[[pd.DataFrame], pd.Series], |
| n_permutations: int = 300, |
| method: str = "permute", |
| block: int = 20, |
| costs: Optional[CostModel] = None, |
| lag: int = 1, |
| max_leverage: float = 1.0, |
| allow_short: bool = True, |
| seed: int = 0, |
| observed: Optional[float] = None, |
| progress: Optional[Callable[[float, str], None]] = None, |
| ) -> PermutationResult: |
| """Run ``signal_fn`` against ``n_permutations`` shuffled markets. |
| |
| ``signal_fn`` must be the strategy's target-exposure generator; it is |
| re-evaluated on every synthetic market, which is the whole point — a rule |
| that only works because of the specific path it was tuned on will fall |
| apart here. |
| """ |
| costs = costs or CostModel() |
| rng = np.random.default_rng(seed) |
|
|
| def sharpe_on(frame: pd.DataFrame) -> float: |
| target = signal_fn(frame) |
| result = run_backtest( |
| frame, |
| target, |
| costs=costs, |
| lag=lag, |
| max_leverage=max_leverage, |
| allow_short=allow_short, |
| ) |
| return result.sharpe |
|
|
| if observed is None: |
| observed = sharpe_on(df) |
|
|
| null = np.empty(n_permutations, dtype=float) |
| for i in range(n_permutations): |
| null[i] = sharpe_on(permute_bars(df, rng, method, block)) |
| if progress is not None and (i % 25 == 0 or i == n_permutations - 1): |
| progress((i + 1) / n_permutations, f"Permutation {i + 1}/{n_permutations}") |
|
|
| |
| |
| p_value = float((1 + np.sum(null >= observed)) / (n_permutations + 1)) |
|
|
| return PermutationResult( |
| observed=float(observed), |
| null=null, |
| p_value=p_value, |
| method=method, |
| n_permutations=n_permutations, |
| ) |
|
|
|
|
| def bootstrap_return_paths(returns: pd.Series, n: int = 500, seed: int = 0) -> np.ndarray: |
| """Bootstrap terminal-wealth outcomes from a realised return stream. |
| |
| Useful for the "how wide is the cone of outcomes?" chart — the same edge |
| can produce wildly different equity curves. |
| """ |
| arr = np.asarray(returns.dropna(), dtype=float) |
| if arr.size == 0: |
| return np.zeros((n, 1)) |
| rng = np.random.default_rng(seed) |
| draws = rng.choice(arr, size=(n, arr.size), replace=True) |
| return np.cumprod(1.0 + draws, axis=1) |
|
|