phacker 0.1.0__cp313-cp313-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phacker/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """""" # start delvewheel patch
2
+ def _delvewheel_patch_1_13_2():
3
+ import os
4
+ if os.path.isdir(libs_dir := os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, 'phacker.libs'))):
5
+ os.add_dll_directory(libs_dir)
6
+
7
+
8
+ _delvewheel_patch_1_13_2()
9
+ del _delvewheel_patch_1_13_2
10
+ # end delvewheel patch
11
+
12
+ try:
13
+ from . import _core
14
+ except ImportError: # sdist built without a compiler
15
+ _core = None
16
+
17
+
18
+ def backend():
19
+ return "numpy" if _core is None else "cpp"
Binary file
phacker/cscv.py ADDED
@@ -0,0 +1,51 @@
1
+ from itertools import combinations
2
+ import numpy as np
3
+
4
+ try:
5
+ from ._core import cscv as _cscv_cpp
6
+ except ImportError:
7
+ _cscv_cpp = None
8
+
9
+
10
+ def sharpe(x):
11
+ # an all-zero column (a config that never trades in this window) scores 0, not NaN
12
+ flat = ~x.any(axis=0)
13
+ m, v = x.mean(axis=0), x.var(axis=0, ddof=1)
14
+ return np.where(flat, 0.0, m / np.sqrt(np.where(flat, 1.0, v)))
15
+
16
+
17
+ def cscv(M, S=16, metric=sharpe):
18
+ """Probability of Backtest Overfitting via CSCV (Bailey et al., Algorithm 2.3).
19
+
20
+ M: T x N array, column n = P&L series of trial n.
21
+ Returns (pbo, logits). Runs the C++ kernel when it is built and metric is sharpe.
22
+ """
23
+ if metric is sharpe and _cscv_cpp is not None:
24
+ return _cscv_cpp(np.asarray(M, dtype=float), S)
25
+ return cscv_reference(M, S, metric)
26
+
27
+
28
+ def cscv_reference(M, S=16, metric=sharpe):
29
+ """NumPy implementation of cscv, for any metric. The C++ kernel is tested against it."""
30
+ M = np.asarray(M, dtype=float)
31
+ T, N = M.shape
32
+ if S % 2 != 0:
33
+ raise ValueError("S must be even")
34
+
35
+ blocks = np.array_split(np.arange(T - T % S), S)
36
+
37
+ logits = []
38
+ for c in combinations(range(S), S // 2):
39
+ is_rows = np.concatenate([blocks[i] for i in c])
40
+ oos_rows = np.concatenate([blocks[i] for i in range(S) if i not in c])
41
+
42
+ R = metric(M[is_rows])
43
+ R_bar = metric(M[oos_rows])
44
+
45
+ n_star = np.argmax(R)
46
+ rank = 1 + np.sum(R_bar < R_bar[n_star]) # 1..N, higher = better; ties rank low
47
+ w = rank / (N + 1)
48
+ logits.append(np.log(w / (1 - w)))
49
+
50
+ logits = np.array(logits)
51
+ return (logits <= 0).mean(), logits
phacker/grid.py ADDED
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator, Sequence
4
+ from dataclasses import dataclass, field
5
+ from itertools import product
6
+ from typing import Literal, NamedTuple
7
+
8
+ import numpy as np
9
+ import numpy.typing as npt
10
+ import pandas as pd
11
+
12
+ Side = Literal["long", "short", "both"]
13
+ FloatArray = npt.NDArray[np.float64]
14
+
15
+ _SIDES: dict[str, tuple[int, ...]] = {"long": (1,), "short": (-1,), "both": (1, -1)}
16
+
17
+
18
+ def linspace(start, stop, num):
19
+ # rounded so grid points like 0.25 print and compare cleanly
20
+ return np.round(np.linspace(start, stop, num), 12)
21
+
22
+
23
+ class Config(NamedTuple):
24
+ indicator: str
25
+ entry: float
26
+ band: float
27
+ hold: int
28
+ side: int
29
+
30
+ @property
31
+ def name(self) -> str:
32
+ side = "long" if self.side > 0 else "short"
33
+ return f"{self.indicator}:entry={self.entry:g}:band={self.band:g}:hold={self.hold}:{side}"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Grid:
38
+ indicators: Sequence[str]
39
+ entry: Sequence[float] | FloatArray
40
+ band: Sequence[float] | FloatArray
41
+ hold: Sequence[int]
42
+ side: Side = "both"
43
+ cost_bps: float = 0.0
44
+
45
+ def __post_init__(self):
46
+ object.__setattr__(self, "indicators", tuple(str(i) for i in self.indicators))
47
+ object.__setattr__(self, "entry", tuple(float(e) for e in self.entry))
48
+ object.__setattr__(self, "band", tuple(float(b) for b in self.band))
49
+ object.__setattr__(self, "hold", tuple(int(h) for h in self.hold))
50
+ if not (self.indicators and self.entry and self.band and self.hold):
51
+ raise ValueError("every grid axis needs at least one value")
52
+ if len(set(self.indicators)) != len(self.indicators):
53
+ raise ValueError("indicators must be unique")
54
+ if any(b <= 0 for b in self.band):
55
+ raise ValueError("band widths must be positive")
56
+ if any(h < 1 for h in self.hold):
57
+ raise ValueError("hold periods must be at least 1 bar")
58
+ if self.side not in _SIDES:
59
+ raise ValueError(f"side must be one of {sorted(_SIDES)}, got {self.side!r}")
60
+ if self.cost_bps < 0:
61
+ raise ValueError("cost_bps must be non-negative")
62
+
63
+ @property
64
+ def sides(self):
65
+ return _SIDES[self.side]
66
+
67
+ @property
68
+ def shape(self):
69
+ return (len(self.indicators), len(self.entry), len(self.band), len(self.hold), len(self.sides))
70
+
71
+ def __len__(self):
72
+ return int(np.prod(self.shape))
73
+
74
+ def __iter__(self):
75
+ # order matches the column order produced by backtest
76
+ for ind, e, b, h, s in product(self.indicators, self.entry, self.band, self.hold, self.sides):
77
+ yield Config(ind, e, b, h, s)
78
+
79
+ def configs(self):
80
+ return list(self)
81
+
82
+
83
+ @dataclass
84
+ class TrialMatrix:
85
+ pnl: FloatArray
86
+ names: list[str] = field(default_factory=list)
87
+ index: pd.Index | None = None
88
+
89
+ def __post_init__(self):
90
+ self.pnl = np.ascontiguousarray(self.pnl, dtype=np.float64)
91
+ if self.pnl.ndim != 2:
92
+ raise ValueError(f"pnl must be 2-D (T x N), got shape {self.pnl.shape}")
93
+ if not self.names:
94
+ self.names = [f"trial_{n}" for n in range(self.pnl.shape[1])]
95
+ if len(self.names) != self.pnl.shape[1]:
96
+ raise ValueError(f"got {len(self.names)} names for {self.pnl.shape[1]} columns")
97
+ if self.index is not None and len(self.index) != self.pnl.shape[0]:
98
+ raise ValueError(f"index length {len(self.index)} does not match T={self.pnl.shape[0]}")
99
+ if not np.all(np.isfinite(self.pnl)):
100
+ raise ValueError("pnl contains NaN or inf")
101
+
102
+ @property
103
+ def T(self):
104
+ return int(self.pnl.shape[0])
105
+
106
+ @property
107
+ def N(self):
108
+ return int(self.pnl.shape[1])
109
+
110
+ @property
111
+ def shape(self):
112
+ return (self.T, self.N)
113
+
114
+ def __array__(self, dtype: npt.DTypeLike = None, copy: bool | None = None):
115
+ return self.pnl if dtype is None else self.pnl.astype(dtype)
116
+
117
+ def to_frame(self):
118
+ return pd.DataFrame(self.pnl, index=self.index, columns=self.names)
119
+
120
+
121
+ def expanding_zscore(x, min_periods):
122
+ # stats at t use only rows 0..t, so there is no lookahead
123
+ if min_periods < 2:
124
+ raise ValueError("min_periods must be at least 2")
125
+ ex = x.astype(np.float64).expanding(min_periods=min_periods)
126
+ sd = ex.std(ddof=1)
127
+ return (x - ex.mean()) / sd.where(sd > 0)
128
+
129
+
130
+ def _held(signal, hold):
131
+ # position is on if any trigger fired in the last `hold` bars, via a windowed cumsum
132
+ T = signal.shape[0]
133
+ cs = np.zeros((T + hold,) + signal.shape[1:], dtype=np.int32)
134
+ np.cumsum(signal, axis=0, out=cs[hold:])
135
+ return (cs[hold:] - cs[:T]) > 0
136
+
137
+
138
+ def backtest(prices, grid, price_col, min_periods):
139
+
140
+ missing = [c for c in (price_col, *grid.indicators) if c not in prices.columns]
141
+ if missing:
142
+ raise KeyError(f"prices is missing columns: {missing}")
143
+
144
+ px = prices[price_col].to_numpy(dtype=np.float64)
145
+ if px.shape[0] < 3:
146
+ raise ValueError("need at least 3 price rows")
147
+ if not np.all(np.isfinite(px)) or np.any(px <= 0):
148
+ raise ValueError(f"{price_col} must be finite and positive")
149
+
150
+ T = px.shape[0] - 1
151
+ r = np.diff(np.log(px))
152
+ cost = grid.cost_bps * 1e-4
153
+ entry = np.asarray(grid.entry)
154
+ band = np.asarray(grid.band)
155
+ I, K, B, H, S = grid.shape
156
+
157
+ out = np.empty((T, I, K, B, H, S), dtype=np.float64)
158
+
159
+ for i, ind in enumerate(grid.indicators):
160
+ z = expanding_zscore(prices[ind], min_periods).to_numpy(dtype=np.float64)
161
+ # NaN z (warmup or zero spread) compares False, so those rows stay flat
162
+ with np.errstate(invalid="ignore"):
163
+ signal = np.abs(z[:, None, None] - entry[None, :, None]) <= band[None, None, :]
164
+
165
+ for j, h in enumerate(grid.hold):
166
+ pos = _held(signal, h).astype(np.float64)
167
+ # pnl_t = pos_{t-1} * r_t, trading cost charged on the bar the position changes
168
+ gross = pos[:-1] * r[:, None, None]
169
+ turn = cost * np.abs(np.diff(pos, axis=0))
170
+ for k, s in enumerate(grid.sides):
171
+ out[:, i, :, :, j, k] = s * gross - turn
172
+
173
+ index = prices.index[1:] if not isinstance(prices.index, pd.RangeIndex) else None
174
+ return TrialMatrix(out.reshape(T, -1), names=[c.name for c in grid], index=index)
phacker/indicators.py ADDED
@@ -0,0 +1,107 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ # adapted from an older project with Johan Naresh
5
+ # https://github.com/johannaresh/CSC-project/blob/main/tickerdataframe.py
6
+
7
+ CLOSE = "Adj_Close"
8
+
9
+
10
+ def _safe_div(num, den):
11
+ # zero denominators give NaN instead of inf so the expanding z-score in grid.py stays finite
12
+ return num / den.where(den != 0)
13
+
14
+
15
+ def _true_range(df, close):
16
+ prev = df[close].shift()
17
+ tr1 = df["High"] - df["Low"]
18
+ tr2 = (df["High"] - prev).abs()
19
+ tr3 = (df["Low"] - prev).abs()
20
+ return pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
21
+
22
+
23
+ def sma(df, n, close=CLOSE): # Simple Moving Average
24
+ return df[close].rolling(window=n).mean()
25
+
26
+
27
+ def ema(df, n, close=CLOSE): # Exponential Moving Average seeded with the first value
28
+ return df[close].ewm(alpha=2 / (n + 1), adjust=False).mean()
29
+
30
+
31
+ def rsi(df, n=14, close=CLOSE): # Relative Strength Index
32
+ delta = df[close].diff()
33
+ avg_gain = delta.clip(lower=0).rolling(window=n).mean()
34
+ avg_loss = (-delta.clip(upper=0)).rolling(window=n).mean()
35
+ return 100 - 100 / (1 + avg_gain / avg_loss)
36
+
37
+
38
+ def atr(df, n=14, close=CLOSE): # Average True Range
39
+ return _true_range(df, close).rolling(window=n).mean()
40
+
41
+
42
+ # OBV increases when price rises on high volume, decreases when it falls on high volume
43
+ def obv(df, close=CLOSE, volume="Volume"):
44
+ direction = np.sign(df[close].diff()).fillna(0)
45
+ return (direction * df[volume]).cumsum()
46
+
47
+
48
+ # shows where the current price sits within its volatility range (0 = lower band, 1 = upper band)
49
+ def bollinger_b(df, n=20, k=2, close=CLOSE):
50
+ mid = df[close].rolling(window=n).mean()
51
+ std = df[close].rolling(window=n).std()
52
+ lower = mid - k * std
53
+ return _safe_div(df[close] - lower, 2 * k * std)
54
+
55
+
56
+ def adx(df, n=14, close=CLOSE): # Average Directional Index
57
+ up = df["High"].diff()
58
+ down = -df["Low"].diff()
59
+
60
+ plus_dm = up.where((up > down) & (up > 0), 0.0)
61
+ minus_dm = down.where((down > up) & (down > 0), 0.0)
62
+
63
+ tr = _true_range(df, close).rolling(window=n).mean()
64
+ plus_di = 100 * _safe_div(plus_dm.rolling(window=n).mean(), tr)
65
+ minus_di = 100 * _safe_div(minus_dm.rolling(window=n).mean(), tr)
66
+
67
+ dx = 100 * _safe_div((plus_di - minus_di).abs(), plus_di + minus_di)
68
+ return dx.rolling(window=n).mean()
69
+
70
+
71
+ def rolling_vol(df, n=20, close=CLOSE): # Rolling Volatility (Std Dev of returns)
72
+ return df[close].pct_change().rolling(window=n).std()
73
+
74
+
75
+ def zscore(df, n=20, close=CLOSE): # Z-Score vs SMA(n)
76
+ mid = df[close].rolling(window=n).mean()
77
+ std = df[close].rolling(window=n).std()
78
+ return _safe_div(df[close] - mid, std)
79
+
80
+
81
+ # names used by Grid and the query layer, mapped to their builders
82
+ INDICATORS = {
83
+ "SMA5": lambda df, close: sma(df, 5, close),
84
+ "SMA20": lambda df, close: sma(df, 20, close),
85
+ "EMA5": lambda df, close: ema(df, 5, close),
86
+ "EMA20": lambda df, close: ema(df, 20, close),
87
+ "RSI14": lambda df, close: rsi(df, 14, close),
88
+ "ATR14": lambda df, close: atr(df, 14, close),
89
+ "OBV": lambda df, close: obv(df, close),
90
+ "BOLL_B20": lambda df, close: bollinger_b(df, 20, 2, close),
91
+ "ADX14": lambda df, close: adx(df, 14, close),
92
+ "VOL20": lambda df, close: rolling_vol(df, 20, close),
93
+ "Z20": lambda df, close: zscore(df, 20, close),
94
+ }
95
+
96
+
97
+ def indicators(df, names=None, close=CLOSE):
98
+ # warmup rows are left as NaN rather than dropped so T matches the price series
99
+ names = list(INDICATORS) if names is None else list(names)
100
+ unknown = [n for n in names if n not in INDICATORS]
101
+ if unknown:
102
+ raise KeyError(f"unknown indicators: {unknown}, available: {list(INDICATORS)}")
103
+
104
+ df = df.copy()
105
+ for name in names:
106
+ df[name] = INDICATORS[name](df, close)
107
+ return df
phacker/stats.py ADDED
@@ -0,0 +1,44 @@
1
+ import numpy as np
2
+
3
+
4
+ def sharpe_se(x):
5
+ """Delta-method standard error of the Sharpe ratio, per column.
6
+
7
+ var(SR) = (1 - g3*SR + (g4 - 1)/4 * SR^2) / (T - 1), g4 = non-excess kurtosis.
8
+ """
9
+ x = np.asarray(x, dtype=float)
10
+ T = x.shape[0]
11
+ d = x - x.mean(axis=0)
12
+ s = d.std(axis=0)
13
+ sr = x.mean(axis=0) / x.std(axis=0, ddof=1)
14
+ g3 = (d**3).mean(axis=0) / s**3
15
+ g4 = (d**4).mean(axis=0) / s**4
16
+ return np.sqrt((1 - g3 * sr + (g4 - 1) / 4 * sr**2) / (T - 1))
17
+
18
+
19
+ def logistic_cdf(x):
20
+ """Exact null CDF of the CSCV logit: omega ~ U(0,1) => logit(omega) ~ Logistic(0,1)."""
21
+ return 1 / (1 + np.exp(-np.asarray(x, dtype=float)))
22
+
23
+
24
+ def logistic_pdf(x):
25
+ p = logistic_cdf(x)
26
+ return p * (1 - p)
27
+
28
+
29
+ def logit_fit(logits):
30
+ """KS distance from the logistic null and mean logit. Descriptive only:
31
+ the CSCV logits are dependent, so no p-value is reported."""
32
+ lam = np.sort(np.asarray(logits, dtype=float))
33
+ n = len(lam)
34
+ F = logistic_cdf(lam)
35
+ i = np.arange(1, n + 1)
36
+ ks = max((i / n - F).max(), (F - (i - 1) / n).max())
37
+ return ks, lam.mean()
38
+
39
+
40
+ def n_eff(M):
41
+ """Effective number of trials: participation ratio of the centred SVD spectrum."""
42
+ M = np.asarray(M, dtype=float)
43
+ s2 = np.linalg.svd(M - M.mean(axis=0), compute_uv=False) ** 2
44
+ return s2.sum() ** 2 / (s2**2).sum()
@@ -0,0 +1,2 @@
1
+ Version: 1.13.2
2
+ Arguments: ['C:\\Users\\runneradmin\\AppData\\Local\\Temp\\cibw-run-hbtco9i5\\cp313-win_amd64\\build\\venv\\Scripts\\delvewheel', 'repair', '-w', 'C:\\Users\\runneradmin\\AppData\\Local\\Temp\\cibw-run-hbtco9i5\\cp313-win_amd64\\repaired_wheel', 'C:\\Users\\runneradmin\\AppData\\Local\\Temp\\cibw-run-hbtco9i5\\cp313-win_amd64\\built_wheel\\phacker-0.1.0-cp313-cp313-win_amd64.whl']
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.4
2
+ Name: phacker
3
+ Version: 0.1.0
4
+ Summary: Probability of backtest overfitting via CSCV (Bailey et al. 2016), with a C++ kernel
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Project-URL: Source, https://github.com/ryousuf569/phacker
8
+ Project-URL: Paper, https://doi.org/10.21314/JCF.2016.322
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: numpy>=1.24
11
+ Requires-Dist: pandas>=2.0
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Description-Content-Type: text/markdown
15
+
16
+ # phacker
17
+
18
+ Measure how likely it is that your best backtest is overfit.
19
+
20
+ If you try many strategy configurations and keep the one with the best in-sample
21
+ Sharpe ratio, part of that Sharpe is selection luck. `phacker` estimates the
22
+ **Probability of Backtest Overfitting (PBO)**: the probability that the configuration
23
+ you picked as best in-sample ranks below the median out-of-sample. It implements
24
+ combinatorially symmetric cross-validation (CSCV) from
25
+
26
+ > Bailey, D., Borwein, J., López de Prado, M., & Zhu, Q. J. (2016).
27
+ > *The Probability of Backtest Overfitting.* Journal of Computational Finance, 20(4), 39–69.
28
+ > [doi:10.21314/JCF.2016.322](https://doi.org/10.21314/JCF.2016.322)
29
+
30
+ CSCV is deterministic: there is no Monte Carlo, and the same input always gives the same
31
+ answer. The core is a C++ kernel (OpenMP, SIMD) with a pure-NumPy fallback.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install phacker
37
+ ```
38
+
39
+ Wheels are built for CPython 3.11–3.13 on Linux (x86_64, aarch64), macOS (Intel,
40
+ Apple Silicon) and Windows (x64). On other platforms pip builds from source, and
41
+ if no C++ compiler is available you get the NumPy implementation alone. Both give
42
+ the same results; only the speed differs.
43
+
44
+ ```python
45
+ import phacker
46
+ phacker.backend() # "cpp" if the compiled kernel is available, else "numpy"
47
+ ```
48
+
49
+ ## Quick start
50
+
51
+ You need a T × N matrix of P&L: one column per configuration you tried, one row per
52
+ period, with all columns on the same time index.
53
+
54
+ ```python
55
+ import numpy as np
56
+ from phacker.cscv import cscv
57
+
58
+ pnl = np.random.default_rng(0).standard_normal((2000, 500)) # 500 pure-noise strategies
59
+ pbo, logits = cscv(pnl, S=16)
60
+
61
+ pbo # 0.317; noise averages 0.5, but one family can land well away from it
62
+ logits.shape # (12870,) = C(16, 8)
63
+ ```
64
+
65
+ **Include every configuration you tried**, including the ones that did badly.
66
+ Leaving out failed trials biases PBO downward.
67
+
68
+ ### Reading the result
69
+
70
+ - **PBO = 0.5** means selection carried no information. The in-sample winner is
71
+ equally likely to land above or below the out-of-sample median. This is what pure
72
+ noise gives, not 0.
73
+ - **PBO near 0** means the in-sample winner reliably stays above the median
74
+ out-of-sample.
75
+ - **PBO above 0.5** means the selection is worse than random. The in-sample winners
76
+ tend to underperform out-of-sample (mean reversion between halves, for example).
77
+
78
+ `logits` holds one value per train/test split, λ = ln(ω / (1 − ω)), where ω is the
79
+ in-sample winner's relative out-of-sample rank. PBO is the fraction with λ ≤ 0.
80
+ With no skill, ω is uniform and λ follows the standard logistic distribution exactly.
81
+
82
+ ## Building a trial matrix from prices
83
+
84
+ `phacker` includes a grid backtester for threshold rules on technical indicators.
85
+
86
+ ```python
87
+ import pandas as pd
88
+ from phacker.indicators import indicators
89
+ from phacker.grid import Grid, backtest, linspace
90
+ from phacker.cscv import cscv
91
+
92
+ # a DataFrame with Adj_Close, High, Low, Volume columns on a DatetimeIndex
93
+ prices = pd.read_csv("NQ.csv", index_col=0, parse_dates=True)
94
+ prices = indicators(prices, ["RSI14", "Z20", "BOLL_B20"])
95
+
96
+ grid = Grid(
97
+ indicators=["RSI14", "Z20", "BOLL_B20"],
98
+ entry=linspace(-2, 2, 17), # z-score levels
99
+ band=[0.25, 0.5, 1.0, 2.0], # half-width around each level
100
+ hold=[1, 5, 10, 20], # bars to hold after a trigger
101
+ side="both", # "long", "short" or "both"
102
+ cost_bps=0.5, # per unit of position change
103
+ )
104
+ M = backtest(prices, grid, price_col="Adj_Close", min_periods=60) # TrialMatrix, 1,632 columns
105
+
106
+ pbo, logits = cscv(M, S=16)
107
+ M.names[0] # 'RSI14:entry=-2:band=0.25:hold=1:long'
108
+ ```
109
+
110
+ For each configuration (indicator, entry, band, hold, side) the rule goes long or short
111
+ whenever the indicator's z-score is within `band` of `entry`, and holds for `hold` bars.
112
+ Positions are lagged one bar, so there is no lookahead. Indicators are standardized
113
+ with an **expanding-window** z-score, so θ means the same thing for every indicator
114
+ and no future data leaks into the past. `min_periods` is the warm-up before the
115
+ z-score is defined.
116
+
117
+ Available indicators: `SMA5`, `SMA20`, `EMA5`, `EMA20`, `RSI14`, `ATR14`, `OBV`,
118
+ `BOLL_B20`, `ADX14`, `VOL20`, `Z20`.
119
+
120
+ To bring your own P&L with labels, wrap it in `TrialMatrix(pnl, names=[...])`.
121
+
122
+ ## Diagnostics
123
+
124
+ ```python
125
+ from phacker.stats import n_eff, logit_fit, sharpe_se
126
+
127
+ n_eff(M) # effective number of independent trials
128
+ logit_fit(logits) # (KS distance from the logistic null, mean logit)
129
+ sharpe_se(returns) # delta-method standard error of each column's Sharpe ratio
130
+ ```
131
+
132
+ - **`n_eff`**: the participation ratio of the centred trial matrix's singular values.
133
+ A grid of 1,632 configurations that mostly trade the same signal can have an
134
+ `n_eff` near 1. High PBO on a family with a small `n_eff` may say more about
135
+ redundancy than about overfitting.
136
+ - **`logit_fit`** compares the logits to the exact logistic null. It's descriptive
137
+ only: the C(S, S/2) splits share blocks, so the logits are dependent and a
138
+ classical KS p-value would be wrong. None is reported.
139
+ - **`sharpe_se`** is √((1 − γ₃·SR + (γ₄ − 1)/4·SR²) / (T − 1)), with skewness γ₃
140
+ and non-excess kurtosis γ₄. Use it to check that Sharpe estimates on
141
+ T/2 rows are reliable enough for your choice of S.
142
+
143
+ ## Choosing S
144
+
145
+ `S` is the number of blocks the rows are split into. It must be even and at most 32.
146
+ CSCV evaluates C(S, S/2) splits, each with T/2 rows on each side:
147
+
148
+ | S | splits |
149
+ |---|---|
150
+ | 8 | 70 |
151
+ | 12 | 924 |
152
+ | 16 | 12,870 |
153
+ | 20 | 184,756 |
154
+
155
+ The paper uses S = 16. A larger S gives more splits but shorter blocks. If T isn't a
156
+ multiple of S, the last `T % S` rows are dropped.
157
+
158
+ ## Custom metrics
159
+
160
+ `cscv` accepts any performance function that maps a `(rows, N)` array to N scores.
161
+ Rows arrive in their original time order, so path-dependent metrics such as drawdown
162
+ work too:
163
+
164
+ ```python
165
+ pbo, logits = cscv(M, S=16, metric=lambda x: x.mean(axis=0))
166
+ ```
167
+
168
+ The C++ kernel computes the Sharpe ratio only. Any other metric runs the NumPy
169
+ implementation (also available directly as `phacker.cscv.cscv_reference`), which is
170
+ much slower at large N.
171
+
172
+ ## Performance
173
+
174
+ With the Sharpe metric, the kernel sums each block once and then reuses those sums.
175
+ Neighbouring splits are visited in revolving-door (Gray code) order, so each split
176
+ needs one block added and one removed, and the out-of-sample sums are the totals minus
177
+ the in-sample ones. Splits are processed in parallel with OpenMP.
178
+
179
+ N = 1,000 strategies, T = 2,000 rows, S = 16, Intel i7-1365U (10 cores), Windows:
180
+
181
+ | backend | time |
182
+ |---|---|
183
+ | NumPy reference | ~250 s |
184
+ | C++ kernel, 1 thread | ~0.3 s |
185
+ | C++ kernel, OpenMP | ~0.05–0.07 s |
186
+
187
+ macOS wheels are built without OpenMP and run single-threaded.
188
+
189
+ ## Behaviour details
190
+
191
+ - **Flat columns.** A configuration with no nonzero returns in a half (it never traded
192
+ there) gets a Sharpe of 0 in that half rather than NaN.
193
+ - **Ties.** The out-of-sample rank is 1 + the number of strategies strictly worse,
194
+ so ties rank low.
195
+ - **First winner.** If several strategies share the best in-sample score, the first
196
+ column wins.
197
+ - Both backends follow these rules and agree to within 1e-12 on the logits.
198
+
199
+ ## Limitations
200
+
201
+ 1. **PBO measures the selection process, not a strategy.** A family where every
202
+ configuration is genuinely good and roughly equal also gives a high PBO (paper
203
+ §5.2). `n_eff` helps tell these cases apart but doesn't settle it.
204
+ 2. **The answer is only as honest as the trial list.** Omitting configurations you
205
+ tried biases PBO down, and so does padding the family with configurations
206
+ designed to fail.
207
+ 3. **Never optimize for low PBO.** Using CSCV as an objective function overfits PBO
208
+ itself.
209
+ 4. **The splits are dependent**, so there's no calibrated goodness-of-fit p-value on
210
+ the logits.
211
+ 5. **Regimes outside the sample are invisible.** PBO can't account for structural
212
+ breaks it never saw.
213
+ 6. **Order-dependent metrics** (max drawdown, Calmar) have no additive block
214
+ statistics, so they run on the slow NumPy path only.
215
+
216
+ ## Development
217
+
218
+ ```bash
219
+ git clone https://github.com/ryousuf569/phacker.git
220
+ cd phacker
221
+ pip install -e ".[test]" # builds the C++ kernel; needs a C++20 compiler
222
+ pytest
223
+ ```
224
+
225
+ `pytest` without installing runs against the source tree on the NumPy backend, and
226
+ the C++ parity tests are skipped. On Windows with MinGW on `PATH`, set
227
+ `CMAKE_GENERATOR="Visual Studio 17 2022"` so the build uses MSVC.
228
+
229
+ ## License
230
+
231
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ phacker/cscv.py,sha256=UUSNy3gab1YwRqpU-J8HY8PXwFqedmElm7PUbgUASIk,1692
2
+ phacker/grid.py,sha256=qZx5eYxF-5jGDTDt_FA0kAxmEaN-_RVPrbF4zIMy8OQ,6450
3
+ phacker/indicators.py,sha256=EFBLo7j9-AeB6K8HrLbyLkpmjUOVPPZA5MDOdRcTNJk,3837
4
+ phacker/stats.py,sha256=gcqXF2hUUWJlt7k7FGIYcVIMYtvYsLLN_B6oUHCoc1M,1391
5
+ phacker/_core.cp313-win_amd64.pyd,sha256=4-HbXslGHddwZpM_Vrvig6zIncNF2DpAcDE5HNA_qAQ,161792
6
+ phacker/__init__.py,sha256=HI-CW2E2ePnTdUzOB9jbYShmnO2HroA0XWgRIQcbqYI,501
7
+ phacker-0.1.0.dist-info/DELVEWHEEL,sha256=s-jG6QA6tKMb3JGUWcxR4ECvB4eKIJA4c_E7w9ftMjc,400
8
+ phacker-0.1.0.dist-info/METADATA,sha256=of7Dx8v8THDxxMroQs3mbSXcP0dGkGqc44uwXcnkiB8,9015
9
+ phacker-0.1.0.dist-info/RECORD,,
10
+ phacker-0.1.0.dist-info/WHEEL,sha256=sq6LgldFBnjvRzPumpzyb8wN76iXDClJOEXkoK-XWzE,105
11
+ phacker-0.1.0.dist-info/licenses/LICENSE,sha256=4kaKcfEDxi1E-OFjGpmaaKNMf8C0KzyV_nqYDUXBXqI,1088
12
+ phacker.libs/msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll,sha256=pMIim9wqKmMKzcCVtNhgCOXD47x3cxdDVPPaT1vrnN4,575056
13
+ phacker.libs/vcomp140-f96f3a14d88d8846f31f3ab38a490304.dll,sha256=-W86FNiNiEbzHzqzikkDBM59bk9w-uQwTGPlnHrqLTA,213072
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: scikit-build-core 1.1.1
3
+ Root-Is-Purelib: false
4
+ Tag: cp313-cp313-win_amd64
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ryousuf569
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.