alphaengine 0.1.0__py3-none-any.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.
- alphaengine/__init__.py +47 -0
- alphaengine/_version.py +15 -0
- alphaengine/core/__init__.py +63 -0
- alphaengine/core/backtest.py +676 -0
- alphaengine/core/factors.py +136 -0
- alphaengine/core/pairs.py +514 -0
- alphaengine/core/performance.py +108 -0
- alphaengine/core/risk.py +127 -0
- alphaengine/core/technical.py +213 -0
- alphaengine/core/validation.py +361 -0
- alphaengine/py.typed +0 -0
- alphaengine/study/__init__.py +32 -0
- alphaengine/study/schema.py +146 -0
- alphaengine/sweep/__init__.py +18 -0
- alphaengine/sweep/runner.py +321 -0
- alphaengine-0.1.0.dist-info/METADATA +157 -0
- alphaengine-0.1.0.dist-info/RECORD +19 -0
- alphaengine-0.1.0.dist-info/WHEEL +4 -0
- alphaengine-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validation tools, the moat: tell the caller when an idea is probably noise.
|
|
3
|
+
|
|
4
|
+
Lifted verbatim (math-identical) from backend/quant/overfitting.py:
|
|
5
|
+
- Deflated Sharpe Ratio (DSR) + Probabilistic Sharpe Ratio (PSR), Bailey & López de Prado (2014). Corrects the Sharpe for the number of
|
|
6
|
+
trials and for non-normal returns.
|
|
7
|
+
- Probability of Backtest Overfitting (PBO) via Combinatorially Symmetric
|
|
8
|
+
Cross-Validation (CSCV), Bailey, Borwein, López de Prado, Zhu (2017).
|
|
9
|
+
|
|
10
|
+
Pure numpy/scipy. No look-ahead, no LLM, no I/O. Deterministic given inputs on
|
|
11
|
+
the pinned stack. The public names match the build spec's 6-tool beta cut
|
|
12
|
+
(`deflated_sharpe`, `pbo_cscv`); behaviour is unchanged from the source.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import itertools
|
|
18
|
+
import math
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from scipy import stats
|
|
22
|
+
|
|
23
|
+
_EULER = 0.5772156649015329
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── Sharpe statistics ───────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _per_period_sharpe(returns: np.ndarray) -> float:
|
|
30
|
+
sd = returns.std(ddof=1)
|
|
31
|
+
if sd == 0 or not math.isfinite(sd):
|
|
32
|
+
return 0.0
|
|
33
|
+
return float(returns.mean() / sd)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def probabilistic_sharpe_ratio(
|
|
37
|
+
sharpe: float,
|
|
38
|
+
n_obs: int,
|
|
39
|
+
skew: float,
|
|
40
|
+
kurtosis: float,
|
|
41
|
+
sr_benchmark: float = 0.0,
|
|
42
|
+
) -> float:
|
|
43
|
+
"""PSR: P(true per-period Sharpe > sr_benchmark). Bailey & LdP (2014).
|
|
44
|
+
|
|
45
|
+
`kurtosis` is Pearson (normal = 3). Returns a probability in [0, 1].
|
|
46
|
+
"""
|
|
47
|
+
if n_obs < 2:
|
|
48
|
+
return 0.0
|
|
49
|
+
denom = 1.0 - skew * sharpe + ((kurtosis - 1.0) / 4.0) * sharpe**2
|
|
50
|
+
if denom <= 0:
|
|
51
|
+
denom = 1e-9
|
|
52
|
+
z = (sharpe - sr_benchmark) * math.sqrt(n_obs - 1) / math.sqrt(denom)
|
|
53
|
+
return float(stats.norm.cdf(z))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def expected_max_sharpe(n_trials: int, trials_sharpe_std: float) -> float:
|
|
57
|
+
"""E[max Sharpe] over N independent trials of noise (per-period units).
|
|
58
|
+
|
|
59
|
+
SR0 = σ_SR · [ (1-γ)·Φ⁻¹(1 − 1/N) + γ·Φ⁻¹(1 − 1/(N·e)) ].
|
|
60
|
+
The benchmark a *real* strategy must beat to be non-spurious.
|
|
61
|
+
"""
|
|
62
|
+
if n_trials < 2 or trials_sharpe_std <= 0:
|
|
63
|
+
return 0.0
|
|
64
|
+
a = stats.norm.ppf(1.0 - 1.0 / n_trials)
|
|
65
|
+
b = stats.norm.ppf(1.0 - 1.0 / (n_trials * math.e))
|
|
66
|
+
return float(trials_sharpe_std * ((1.0 - _EULER) * a + _EULER * b))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def deflated_sharpe(
|
|
70
|
+
returns: list[float],
|
|
71
|
+
*,
|
|
72
|
+
n_trials: int,
|
|
73
|
+
trials_sharpe_std: float | None = None,
|
|
74
|
+
) -> dict:
|
|
75
|
+
"""Deflated Sharpe Ratio for a return stream.
|
|
76
|
+
|
|
77
|
+
DSR = PSR(SR0), where SR0 = expected max Sharpe across `n_trials`. A DSR
|
|
78
|
+
near 1 means the result survives the multiple-testing correction; near 0
|
|
79
|
+
means it's probably noise. `trials_sharpe_std` should come from the
|
|
80
|
+
caller's hypothesis ledger; when absent we estimate it from the result's
|
|
81
|
+
own sampling error (a conservative lower bound on selection variance).
|
|
82
|
+
"""
|
|
83
|
+
arr = np.asarray([float(r) for r in (returns or []) if r is not None], dtype=float)
|
|
84
|
+
n = arr.size
|
|
85
|
+
if n < 8:
|
|
86
|
+
return {"error": "need >= 8 observations", "n_obs": int(n)}
|
|
87
|
+
|
|
88
|
+
sr = _per_period_sharpe(arr)
|
|
89
|
+
skew = float(stats.skew(arr, bias=False)) if n > 2 else 0.0
|
|
90
|
+
kurt = float(stats.kurtosis(arr, fisher=False, bias=False)) if n > 3 else 3.0
|
|
91
|
+
psr0 = probabilistic_sharpe_ratio(sr, n, skew, kurt, 0.0)
|
|
92
|
+
|
|
93
|
+
# Sampling SD of the Sharpe estimator under the null (Lo, 2002), a floor
|
|
94
|
+
# for trial-Sharpe dispersion when the caller doesn't supply one.
|
|
95
|
+
sr_est_sd = math.sqrt((1.0 - skew * sr + ((kurt - 1.0) / 4.0) * sr**2) / (n - 1)) if n > 1 else 0.0
|
|
96
|
+
std_for_max = trials_sharpe_std if (trials_sharpe_std and trials_sharpe_std > 0) else sr_est_sd
|
|
97
|
+
sr0 = expected_max_sharpe(max(2, n_trials), std_for_max)
|
|
98
|
+
dsr = probabilistic_sharpe_ratio(sr, n, skew, kurt, sr0)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"n_obs": int(n),
|
|
102
|
+
"n_trials": int(n_trials),
|
|
103
|
+
"sharpe_per_period": round(sr, 4),
|
|
104
|
+
"sharpe_annualized": round(sr * math.sqrt(252), 4),
|
|
105
|
+
"skew": round(skew, 4),
|
|
106
|
+
"kurtosis": round(kurt, 4),
|
|
107
|
+
"psr_vs_zero": round(psr0, 4),
|
|
108
|
+
"sr0_expected_max": round(sr0, 4),
|
|
109
|
+
"deflated_sharpe": round(dsr, 4),
|
|
110
|
+
"trials_sharpe_std": round(std_for_max, 4),
|
|
111
|
+
# The headline shown INSTEAD of raw Sharpe.
|
|
112
|
+
"verdict": ("likely_noise" if dsr < 0.5 else "marginal" if dsr < 0.9 else "robust"),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def min_track_record_length(
|
|
117
|
+
returns: list[float],
|
|
118
|
+
*,
|
|
119
|
+
sr_benchmark: float = 0.0,
|
|
120
|
+
confidence: float = 0.95,
|
|
121
|
+
) -> dict:
|
|
122
|
+
"""Minimum Track Record Length (Bailey & López de Prado, 2012): the number of
|
|
123
|
+
observations needed for the observed Sharpe to be statistically greater than
|
|
124
|
+
`sr_benchmark` at `confidence`. Answers "is this backtest long enough to trust,
|
|
125
|
+
or do I need more history before believing the Sharpe?"
|
|
126
|
+
|
|
127
|
+
MinTRL = 1 + [1 - γ3·SR + ((γ4-1)/4)·SR²] · (Z_α / (SR - SR*))² (per-period).
|
|
128
|
+
Returns the required count + whether the actual sample already clears it.
|
|
129
|
+
"""
|
|
130
|
+
arr = np.asarray([float(r) for r in (returns or []) if r is not None], dtype=float)
|
|
131
|
+
n = arr.size
|
|
132
|
+
if n < 8:
|
|
133
|
+
return {"error": "need >= 8 observations", "n_obs": int(n)}
|
|
134
|
+
sr = _per_period_sharpe(arr)
|
|
135
|
+
if sr <= sr_benchmark:
|
|
136
|
+
return {
|
|
137
|
+
"n_obs": int(n),
|
|
138
|
+
"sharpe_per_period": round(sr, 4),
|
|
139
|
+
"min_track_record_length": None,
|
|
140
|
+
"sufficient": False,
|
|
141
|
+
"note": "observed Sharpe <= benchmark; not distinguishable at any length",
|
|
142
|
+
}
|
|
143
|
+
skew = float(stats.skew(arr, bias=False)) if n > 2 else 0.0
|
|
144
|
+
kurt = float(stats.kurtosis(arr, fisher=False, bias=False)) if n > 3 else 3.0
|
|
145
|
+
z = float(stats.norm.ppf(confidence))
|
|
146
|
+
mintrl = 1.0 + (1.0 - skew * sr + ((kurt - 1.0) / 4.0) * sr**2) * (z / (sr - sr_benchmark)) ** 2
|
|
147
|
+
mintrl = max(1.0, float(mintrl))
|
|
148
|
+
return {
|
|
149
|
+
"n_obs": int(n),
|
|
150
|
+
"sharpe_per_period": round(sr, 4),
|
|
151
|
+
"confidence": confidence,
|
|
152
|
+
"min_track_record_length": round(mintrl, 1),
|
|
153
|
+
"min_track_record_years": round(mintrl / 252.0, 2),
|
|
154
|
+
"sufficient": bool(n >= mintrl),
|
|
155
|
+
"shortfall_obs": max(0, int(math.ceil(mintrl - n))),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── PBO via CSCV ────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _block_sharpe(sums: np.ndarray, sqs: np.ndarray, counts: np.ndarray, blocks: list[int]) -> np.ndarray:
|
|
163
|
+
"""Per-config Sharpe over the selected blocks from precomputed moments."""
|
|
164
|
+
s = sums[blocks].sum(axis=0)
|
|
165
|
+
q = sqs[blocks].sum(axis=0)
|
|
166
|
+
c = counts[blocks].sum()
|
|
167
|
+
mean = s / c
|
|
168
|
+
var = q / c - mean**2
|
|
169
|
+
sd = np.sqrt(np.maximum(var, 0.0))
|
|
170
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
171
|
+
return np.where(sd > 0, mean / sd, 0.0)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def pbo_cscv(pnl_matrix, n_splits: int = 10, max_combos: int = 2000) -> dict:
|
|
175
|
+
"""Probability of Backtest Overfitting via CSCV (Bailey et al. 2017).
|
|
176
|
+
|
|
177
|
+
`pnl_matrix`: shape (T observations, N configurations) of per-period
|
|
178
|
+
returns/PnL, one column per strategy variant. Splits T into `n_splits`
|
|
179
|
+
contiguous blocks, enumerates balanced in-sample/out-of-sample partitions,
|
|
180
|
+
and for each picks the IS-best config and measures its OOS rank. PBO =
|
|
181
|
+
fraction of partitions where the IS-best config lands below the OOS median.
|
|
182
|
+
"""
|
|
183
|
+
M = np.asarray(pnl_matrix, dtype=float)
|
|
184
|
+
if M.ndim != 2 or M.shape[1] < 2 or M.shape[0] < n_splits * 2:
|
|
185
|
+
return {"error": "need (T>=2S observations, N>=2 configs)", "shape": list(M.shape)}
|
|
186
|
+
T, N = M.shape
|
|
187
|
+
if n_splits % 2 != 0:
|
|
188
|
+
n_splits -= 1 # CSCV needs an even number of blocks to halve
|
|
189
|
+
|
|
190
|
+
blocks = np.array_split(np.arange(T), n_splits)
|
|
191
|
+
# Precompute per-block moments per config.
|
|
192
|
+
sums = np.vstack([M[b].sum(axis=0) for b in blocks]) # (S, N)
|
|
193
|
+
sqs = np.vstack([(M[b] ** 2).sum(axis=0) for b in blocks]) # (S, N)
|
|
194
|
+
counts = np.array([len(b) for b in blocks], dtype=float) # (S,)
|
|
195
|
+
|
|
196
|
+
block_ids = list(range(n_splits))
|
|
197
|
+
half = n_splits // 2
|
|
198
|
+
combos = list(itertools.combinations(block_ids, half))
|
|
199
|
+
if len(combos) > max_combos:
|
|
200
|
+
# Deterministic subsample to bound cost on large S.
|
|
201
|
+
step = max(1, len(combos) // max_combos)
|
|
202
|
+
combos = combos[::step][:max_combos]
|
|
203
|
+
|
|
204
|
+
logits = []
|
|
205
|
+
for is_blocks in combos:
|
|
206
|
+
oos_blocks = [b for b in block_ids if b not in is_blocks]
|
|
207
|
+
is_perf = _block_sharpe(sums, sqs, counts, list(is_blocks))
|
|
208
|
+
oos_perf = _block_sharpe(sums, sqs, counts, oos_blocks)
|
|
209
|
+
n_star = int(np.argmax(is_perf))
|
|
210
|
+
# Rank of the IS-best config OOS (1 = worst .. N = best).
|
|
211
|
+
order = np.argsort(np.argsort(oos_perf)) # ranks 0..N-1
|
|
212
|
+
rank = order[n_star] + 1
|
|
213
|
+
w = rank / (N + 1)
|
|
214
|
+
w = min(max(w, 1e-6), 1 - 1e-6)
|
|
215
|
+
logits.append(math.log(w / (1.0 - w)))
|
|
216
|
+
|
|
217
|
+
logits_arr = np.asarray(logits)
|
|
218
|
+
pbo = float(np.mean(logits_arr <= 0.0)) if logits_arr.size else float("nan")
|
|
219
|
+
return {
|
|
220
|
+
"pbo": round(pbo, 4),
|
|
221
|
+
"n_partitions": int(logits_arr.size),
|
|
222
|
+
"n_configs": int(N),
|
|
223
|
+
"n_splits": int(n_splits),
|
|
224
|
+
"logit_mean": round(float(np.mean(logits_arr)), 4) if logits_arr.size else None,
|
|
225
|
+
"verdict": ("overfit" if pbo > 0.5 else "acceptable" if pbo > 0.2 else "robust"),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── CPCV, Combinatorial Purged Cross-Validation (single return stream) ──────
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cpcv_score(
|
|
233
|
+
returns: list[float],
|
|
234
|
+
*,
|
|
235
|
+
n_groups: int = 8,
|
|
236
|
+
n_test_groups: int = 2,
|
|
237
|
+
purge: int = 1,
|
|
238
|
+
embargo: int = 1,
|
|
239
|
+
n_trials: int = 1,
|
|
240
|
+
max_paths: int = 2000,
|
|
241
|
+
) -> dict:
|
|
242
|
+
"""Combinatorial Purged Cross-Validation (López de Prado, 2018) on ONE return
|
|
243
|
+
stream, the robustness read a single backtest Sharpe hides.
|
|
244
|
+
|
|
245
|
+
Splits the T returns into `n_groups` contiguous groups and enumerates every
|
|
246
|
+
C(n_groups, n_test_groups) way of holding out `n_test_groups` groups as an
|
|
247
|
+
out-of-sample (OOS) test PATH. Each held-out block is PURGED (`purge` obs
|
|
248
|
+
dropped at its leading edge, where a multi-bar return label could overlap the
|
|
249
|
+
preceding training block) and EMBARGOED (`embargo` obs dropped at its trailing
|
|
250
|
+
edge). For every path we measure the OOS Sharpe and, when the path is long
|
|
251
|
+
enough (>= 8 obs), its deflated Sharpe (haircut by `n_trials`). The output is
|
|
252
|
+
the DISTRIBUTION of those metrics across all paths.
|
|
253
|
+
|
|
254
|
+
This reuses the combinatorial-block machinery of `pbo_cscv` (itertools
|
|
255
|
+
combinations of contiguous blocks) but differs in purpose: pbo_cscv needs a
|
|
256
|
+
matrix of competing configs to measure SELECTION overfitting; cpcv_score
|
|
257
|
+
scores a SINGLE strategy's stability across many purged held-out partitions.
|
|
258
|
+
A 2024 controlled study (ScienceDirect S0950705124011110) finds CPCV superior
|
|
259
|
+
to K-Fold / Purged-K-Fold / Walk-Forward on both PBO and the DSR test stat.
|
|
260
|
+
|
|
261
|
+
Purge/embargo are effectively no-ops for the non-overlapping 1-bar returns the
|
|
262
|
+
backtester emits, but are applied so overlapping-label callers are covered.
|
|
263
|
+
"""
|
|
264
|
+
arr = np.asarray([float(r) for r in (returns or []) if r is not None], dtype=float)
|
|
265
|
+
n = arr.size
|
|
266
|
+
if n < 8:
|
|
267
|
+
return {"error": "need >= 8 observations", "n_obs": int(n)}
|
|
268
|
+
n_groups = int(n_groups)
|
|
269
|
+
n_test_groups = int(n_test_groups)
|
|
270
|
+
if n_groups < 2 or n_groups > n:
|
|
271
|
+
return {"error": "need 2 <= n_groups <= n_obs", "n_obs": int(n), "n_groups": n_groups}
|
|
272
|
+
if not (1 <= n_test_groups < n_groups):
|
|
273
|
+
return {"error": "need 1 <= n_test_groups < n_groups", "n_test_groups": n_test_groups}
|
|
274
|
+
purge = max(0, int(purge))
|
|
275
|
+
embargo = max(0, int(embargo))
|
|
276
|
+
|
|
277
|
+
groups = list(np.array_split(np.arange(n), n_groups))
|
|
278
|
+
combos = list(itertools.combinations(range(n_groups), n_test_groups))
|
|
279
|
+
if len(combos) > max_paths: # deterministic subsample to bound cost
|
|
280
|
+
step = max(1, len(combos) // max_paths)
|
|
281
|
+
combos = combos[::step][:max_paths]
|
|
282
|
+
|
|
283
|
+
sharpes: list[float] = []
|
|
284
|
+
dsrs: list[float] = []
|
|
285
|
+
n_short = 0
|
|
286
|
+
for test_ids in combos:
|
|
287
|
+
parts = []
|
|
288
|
+
for gi in test_ids:
|
|
289
|
+
block = groups[gi]
|
|
290
|
+
if purge or embargo:
|
|
291
|
+
hi = block.size - embargo
|
|
292
|
+
block = block[purge:hi] if hi > purge else block[:0]
|
|
293
|
+
if block.size:
|
|
294
|
+
parts.append(block)
|
|
295
|
+
if not parts:
|
|
296
|
+
continue
|
|
297
|
+
path = arr[np.sort(np.concatenate(parts))]
|
|
298
|
+
if path.size < 2:
|
|
299
|
+
continue
|
|
300
|
+
sharpes.append(_per_period_sharpe(path) * math.sqrt(252))
|
|
301
|
+
if path.size >= 8:
|
|
302
|
+
try:
|
|
303
|
+
d = deflated_sharpe(path.tolist(), n_trials=max(1, int(n_trials)))
|
|
304
|
+
except (ValueError, FloatingPointError):
|
|
305
|
+
d = {"error": "dsr undefined for this path"} # e.g. degenerate/near-riskless path
|
|
306
|
+
if "error" not in d:
|
|
307
|
+
dsrs.append(d["deflated_sharpe"])
|
|
308
|
+
else:
|
|
309
|
+
n_short += 1
|
|
310
|
+
|
|
311
|
+
if not sharpes:
|
|
312
|
+
return {
|
|
313
|
+
"error": "no evaluable CPCV paths (groups too small after purge/embargo)",
|
|
314
|
+
"n_obs": int(n),
|
|
315
|
+
"n_groups": n_groups,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
sh = np.asarray(sharpes)
|
|
319
|
+
out = {
|
|
320
|
+
"n_obs": int(n),
|
|
321
|
+
"n_groups": n_groups,
|
|
322
|
+
"n_test_groups": n_test_groups,
|
|
323
|
+
"purge": purge,
|
|
324
|
+
"embargo": embargo,
|
|
325
|
+
"n_trials": int(n_trials),
|
|
326
|
+
"n_paths": int(sh.size),
|
|
327
|
+
# LdP's reconstructed-path count, reported for reference.
|
|
328
|
+
"n_backtest_paths_theoretical": int(math.comb(n_groups, n_test_groups) * n_test_groups // n_groups),
|
|
329
|
+
"sharpe_annualized": {
|
|
330
|
+
"mean": round(float(sh.mean()), 4),
|
|
331
|
+
"std": round(float(sh.std(ddof=1)) if sh.size > 1 else 0.0, 4),
|
|
332
|
+
"min": round(float(sh.min()), 4),
|
|
333
|
+
"p25": round(float(np.percentile(sh, 25)), 4),
|
|
334
|
+
"median": round(float(np.percentile(sh, 50)), 4),
|
|
335
|
+
"p75": round(float(np.percentile(sh, 75)), 4),
|
|
336
|
+
"max": round(float(sh.max()), 4),
|
|
337
|
+
},
|
|
338
|
+
"pct_paths_positive": round(float(np.mean(sh > 0)), 4),
|
|
339
|
+
"n_paths_short_for_dsr": int(n_short),
|
|
340
|
+
}
|
|
341
|
+
med_sr = float(np.median(sh))
|
|
342
|
+
if dsrs:
|
|
343
|
+
d = np.asarray(dsrs)
|
|
344
|
+
out["deflated_sharpe"] = {
|
|
345
|
+
"mean": round(float(d.mean()), 4),
|
|
346
|
+
"min": round(float(d.min()), 4),
|
|
347
|
+
"median": round(float(np.percentile(d, 50)), 4),
|
|
348
|
+
"max": round(float(d.max()), 4),
|
|
349
|
+
"pct_paths_robust": round(float(np.mean(d >= 0.9)), 4),
|
|
350
|
+
}
|
|
351
|
+
med_dsr = float(np.median(d))
|
|
352
|
+
out["verdict"] = (
|
|
353
|
+
"likely_noise"
|
|
354
|
+
if (med_sr <= 0 or med_dsr < 0.5)
|
|
355
|
+
else "robust"
|
|
356
|
+
if med_dsr >= 0.9
|
|
357
|
+
else "inconclusive"
|
|
358
|
+
)
|
|
359
|
+
else:
|
|
360
|
+
out["verdict"] = "likely_noise" if med_sr <= 0 else "inconclusive"
|
|
361
|
+
return out
|
alphaengine/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""The study: what was tried, what came back, and on what.
|
|
2
|
+
|
|
3
|
+
The artifact that crosses the boundary between the person who ran the research
|
|
4
|
+
and the person who has to decide on it. It carries the claim WITH its
|
|
5
|
+
qualifiers, the trial count, the deflation, the data identity, because the
|
|
6
|
+
documented failure mode is a headline Sharpe outliving its caveats into a deck
|
|
7
|
+
where the footnotes did not follow.
|
|
8
|
+
|
|
9
|
+
Writes to disk by default. No account, no upload, no network. The hosted
|
|
10
|
+
platform makes a study shareable and durable across a firm; it is not what makes
|
|
11
|
+
one exist.
|
|
12
|
+
|
|
13
|
+
FORMAT
|
|
14
|
+
JSON with an explicit `schema_version`, because a study written today has to
|
|
15
|
+
parse in two years. JSON rather than a binary format so a PM can open it in
|
|
16
|
+
a text editor and a reviewer can diff two of them, an artifact that needs
|
|
17
|
+
our software to read is a worse artifact.
|
|
18
|
+
|
|
19
|
+
Protobuf is the intended normative definition once the C++ path lands: the
|
|
20
|
+
same schema then generates a writer for a C++ engine and a reader for our
|
|
21
|
+
Python, instead of each side maintaining a bespoke parser. The JSON here is
|
|
22
|
+
already field-stable, so that is an addition rather than a migration.
|
|
23
|
+
|
|
24
|
+
WHAT IS NOT IN IT
|
|
25
|
+
No price series, no returns matrix, no parameter values unless the author
|
|
26
|
+
opted in. A study is derived facts and references, the inputs stay with
|
|
27
|
+
whoever owns them.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from .schema import SCHEMA_VERSION, Study, load, save
|
|
31
|
+
|
|
32
|
+
__all__ = ["Study", "save", "load", "SCHEMA_VERSION"]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""The study schema.
|
|
2
|
+
|
|
3
|
+
VERSIONING RULE
|
|
4
|
+
`schema_version` is MAJOR.MINOR. A minor bump adds optional fields and older
|
|
5
|
+
readers must keep working. A major bump means a field changed meaning or was
|
|
6
|
+
removed, and a reader is entitled to refuse.
|
|
7
|
+
|
|
8
|
+
`load()` refuses a major version it does not know rather than guessing. A
|
|
9
|
+
study that silently half-parses is worse than one that fails loudly: the
|
|
10
|
+
figures in it get quoted to investors.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import asdict, dataclass, field
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .._version import __version__
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = "1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = ["Study", "save", "load", "SCHEMA_VERSION"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Study:
|
|
30
|
+
"""One piece of research, with everything needed to judge it.
|
|
31
|
+
|
|
32
|
+
Field order below is the order a reader needs them in: what was studied,
|
|
33
|
+
how hard it was searched, what came back, and what it can be checked
|
|
34
|
+
against.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# ── what this is ────────────────────────────────────────────────────────
|
|
38
|
+
label: str = ""
|
|
39
|
+
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
40
|
+
|
|
41
|
+
# ── the search ──────────────────────────────────────────────────────────
|
|
42
|
+
# n_trials is the count of configurations actually executed. `n_trials_source`
|
|
43
|
+
# records HOW it was obtained, because a derived count and an asserted one
|
|
44
|
+
# are not the same evidence and a reader is entitled to know which they hold.
|
|
45
|
+
n_trials: int = 0
|
|
46
|
+
n_trials_source: str = "derived_from_grid"
|
|
47
|
+
grid_keys: list[str] = field(default_factory=list)
|
|
48
|
+
n_failed_trials: int = 0
|
|
49
|
+
|
|
50
|
+
# ── identity of the inputs ──────────────────────────────────────────────
|
|
51
|
+
# A content hash, not a name. A label can be changed to escape a history;
|
|
52
|
+
# an array cannot. Two studies over the same data are recognisably the same
|
|
53
|
+
# segment however they were titled.
|
|
54
|
+
data_hash: str = ""
|
|
55
|
+
data_description: str = ""
|
|
56
|
+
|
|
57
|
+
# ── what came back ──────────────────────────────────────────────────────
|
|
58
|
+
verdict: str | None = None
|
|
59
|
+
deflated_sharpe: float | None = None
|
|
60
|
+
psr_vs_zero: float | None = None
|
|
61
|
+
sr0_expected_max: float | None = None
|
|
62
|
+
min_track_record_length: dict[str, Any] | None = None
|
|
63
|
+
performance: dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
# Reported, deliberately not part of the verdict: PBO answers whether the
|
|
66
|
+
# choice AMONG configurations was informative, which is a different question
|
|
67
|
+
# from whether the edge is real.
|
|
68
|
+
selection: dict[str, Any] | None = None
|
|
69
|
+
|
|
70
|
+
# ── the shape of the neighbourhood ──────────────────────────────────────
|
|
71
|
+
surface: dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
# ── optional, off by default ────────────────────────────────────────────
|
|
74
|
+
# The parameter grid is frequently bigger IP than the return series.
|
|
75
|
+
best_params: dict[str, Any] | None = None
|
|
76
|
+
|
|
77
|
+
# ── provenance ──────────────────────────────────────────────────────────
|
|
78
|
+
schema_version: str = SCHEMA_VERSION
|
|
79
|
+
engine_version: str = __version__
|
|
80
|
+
notes: str = ""
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> dict[str, Any]:
|
|
83
|
+
return asdict(self)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_sweep(
|
|
87
|
+
cls,
|
|
88
|
+
result: Any,
|
|
89
|
+
*,
|
|
90
|
+
label: str = "",
|
|
91
|
+
data_description: str = "",
|
|
92
|
+
notes: str = "",
|
|
93
|
+
risk_free_rate: float = 0.0,
|
|
94
|
+
) -> Study:
|
|
95
|
+
"""Build a study from a SweepResult.
|
|
96
|
+
|
|
97
|
+
Kept here rather than as a method on SweepResult so the sweep module has
|
|
98
|
+
no opinion about serialisation, and a study can be assembled from
|
|
99
|
+
something other than a sweep later.
|
|
100
|
+
"""
|
|
101
|
+
v = result.verdict(risk_free_rate=risk_free_rate)
|
|
102
|
+
return cls(
|
|
103
|
+
label=label,
|
|
104
|
+
n_trials=v["n_trials"],
|
|
105
|
+
n_trials_source=v["n_trials_source"],
|
|
106
|
+
grid_keys=list(result.grid_keys),
|
|
107
|
+
n_failed_trials=sum(1 for t in result.trials if t.failed is not None),
|
|
108
|
+
data_hash=v["data_hash"],
|
|
109
|
+
data_description=data_description,
|
|
110
|
+
verdict=v.get("verdict"),
|
|
111
|
+
deflated_sharpe=v.get("deflated_sharpe"),
|
|
112
|
+
psr_vs_zero=v.get("psr_vs_zero"),
|
|
113
|
+
sr0_expected_max=v.get("sr0_expected_max"),
|
|
114
|
+
min_track_record_length=v.get("min_track_record_length"),
|
|
115
|
+
performance=v.get("performance", {}),
|
|
116
|
+
selection=v.get("selection"),
|
|
117
|
+
surface=result.surface(),
|
|
118
|
+
best_params=v.get("best_params"),
|
|
119
|
+
notes=notes,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def save(study: Study, path: str | Path) -> Path:
|
|
124
|
+
"""Write a study to disk. Local file, no network, no account."""
|
|
125
|
+
p = Path(path)
|
|
126
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
p.write_text(json.dumps(study.to_dict(), indent=2, default=str), encoding="utf8")
|
|
128
|
+
return p
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load(path: str | Path) -> Study:
|
|
132
|
+
"""Read a study, refusing a schema major version this build does not know."""
|
|
133
|
+
raw = json.loads(Path(path).read_text(encoding="utf8"))
|
|
134
|
+
|
|
135
|
+
got = str(raw.get("schema_version", "0"))
|
|
136
|
+
if got.split(".")[0] != SCHEMA_VERSION.split(".")[0]:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"study schema version {got} cannot be read by alphaengine {__version__}, "
|
|
139
|
+
f"which understands {SCHEMA_VERSION}. Refusing rather than partially parsing: "
|
|
140
|
+
f"the figures in a study get quoted to investors."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
known = {f for f in Study.__dataclass_fields__}
|
|
144
|
+
# Unknown keys are dropped, not fatal, that is what makes a minor bump
|
|
145
|
+
# forward-compatible for an older reader.
|
|
146
|
+
return Study(**{k: v for k, v in raw.items() if k in known})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Run the parameter grid, so nobody has to be asked how many things they tried.
|
|
2
|
+
|
|
3
|
+
The correction that makes a Sharpe ratio honest needs one input: the number of
|
|
4
|
+
variants tested. Ask a person for it and you get the number that flatters
|
|
5
|
+
them. Not through dishonesty: nobody counts what they threw away, and the
|
|
6
|
+
count is genuinely hard to reconstruct after the fact.
|
|
7
|
+
|
|
8
|
+
Run the grid and the count is `len(grid)`. The question never gets asked.
|
|
9
|
+
|
|
10
|
+
The deflation is the by-product. What you get back is the NEIGHBOURHOOD: whether
|
|
11
|
+
your result sits on a broad plateau or a knife edge, and where the plateau's
|
|
12
|
+
centre is. That is the output worth having, because it makes the strategy better
|
|
13
|
+
rather than just making the number smaller.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .runner import SweepResult, sweep
|
|
17
|
+
|
|
18
|
+
__all__ = ["sweep", "SweepResult"]
|