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.
@@ -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"]