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 +19 -0
- phacker/_core.cp313-win_amd64.pyd +0 -0
- phacker/cscv.py +51 -0
- phacker/grid.py +174 -0
- phacker/indicators.py +107 -0
- phacker/stats.py +44 -0
- phacker-0.1.0.dist-info/DELVEWHEEL +2 -0
- phacker-0.1.0.dist-info/METADATA +231 -0
- phacker-0.1.0.dist-info/RECORD +13 -0
- phacker-0.1.0.dist-info/WHEEL +5 -0
- phacker-0.1.0.dist-info/licenses/LICENSE +21 -0
- phacker.libs/msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll +0 -0
- phacker.libs/vcomp140-f96f3a14d88d8846f31f3ab38a490304.dll +0 -0
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,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.
|
|
Binary file
|
|
Binary file
|