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,321 @@
|
|
|
1
|
+
"""The grid runner.
|
|
2
|
+
|
|
3
|
+
WHAT THIS DELIBERATELY DOES NOT DO
|
|
4
|
+
It does not backtest. `sweep()` takes YOUR function and calls it once per
|
|
5
|
+
parameter combination. We orchestrate and measure; you simulate. Shipping a
|
|
6
|
+
backtester would mean competing with the engine you already trust, and
|
|
7
|
+
inheriting responsibility for its correctness, which is not a trade worth
|
|
8
|
+
making for a library whose entire claim is that its numbers are reliable.
|
|
9
|
+
|
|
10
|
+
WHY THE TRIAL COUNT IS NOT A PARAMETER
|
|
11
|
+
Because a parameter is a place to be optimistic. `n_trials` is `len(grid)`,
|
|
12
|
+
computed from the grid actually iterated, and there is no argument to
|
|
13
|
+
override it. That is the single design decision this module exists for.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import itertools
|
|
20
|
+
import json
|
|
21
|
+
import math
|
|
22
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import numpy.typing as npt
|
|
29
|
+
|
|
30
|
+
from ..core import deflated_sharpe, min_track_record_length, pbo_cscv, performance_report
|
|
31
|
+
|
|
32
|
+
__all__ = ["sweep", "SweepResult"]
|
|
33
|
+
|
|
34
|
+
# A parameter set is a plain dict; a returns series is a sequence of floats.
|
|
35
|
+
Params = Mapping[str, Any]
|
|
36
|
+
Returns = Sequence[float]
|
|
37
|
+
BacktestFn = Callable[..., Returns]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _expand(grid: Mapping[str, Iterable[Any]]) -> list[dict[str, Any]]:
|
|
41
|
+
"""Cartesian product of the grid, in a deterministic order.
|
|
42
|
+
|
|
43
|
+
Sorted by key so the same grid produces the same ordering on any machine and
|
|
44
|
+
any Python version, the trial index is part of what gets recorded, so it
|
|
45
|
+
cannot depend on dict iteration order.
|
|
46
|
+
"""
|
|
47
|
+
keys = sorted(grid)
|
|
48
|
+
if not keys:
|
|
49
|
+
# itertools.product() with no iterables yields ONE empty tuple, so an
|
|
50
|
+
# empty grid would quietly run once and report n_trials=1. That is
|
|
51
|
+
# exactly the flattering count this module exists to prevent, so it has
|
|
52
|
+
# to fail rather than pass.
|
|
53
|
+
return []
|
|
54
|
+
values = [list(grid[k]) for k in keys]
|
|
55
|
+
empty = [k for k, v in zip(keys, values, strict=True) if not v]
|
|
56
|
+
if empty:
|
|
57
|
+
# One empty axis makes the whole product empty. A caller who wrote
|
|
58
|
+
# {"fast": []} meant to sweep something and should hear about it.
|
|
59
|
+
raise ValueError(f"grid axis has no values: {empty}")
|
|
60
|
+
return [dict(zip(keys, combo, strict=True)) for combo in itertools.product(*values)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _hash_data(data: Any) -> str:
|
|
64
|
+
"""Content hash of the input series.
|
|
65
|
+
|
|
66
|
+
This is the segment identity, and it is deliberately NOT a user-supplied
|
|
67
|
+
label: a name can be changed to escape a history, an array cannot. Two runs
|
|
68
|
+
over the same data are recognisably the same segment even if the researcher
|
|
69
|
+
renamed the experiment.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
arr = np.asarray(data, dtype=float)
|
|
73
|
+
return hashlib.sha256(np.ascontiguousarray(arr).tobytes()).hexdigest()[:16]
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
# Not array-shaped (a dict of price series, say). Hash a canonical
|
|
76
|
+
# JSON rendering so the identity is still stable across runs.
|
|
77
|
+
blob = json.dumps(data, sort_keys=True, default=str).encode()
|
|
78
|
+
return hashlib.sha256(blob).hexdigest()[:16]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _hash_params(p: Params) -> str:
|
|
82
|
+
return hashlib.sha256(json.dumps(dict(p), sort_keys=True, default=str).encode()).hexdigest()[:12]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _sharpe(returns: npt.NDArray[np.float64]) -> float:
|
|
86
|
+
sd = float(returns.std(ddof=1)) if returns.size > 1 else 0.0
|
|
87
|
+
return float(returns.mean() / sd) if sd > 0 else 0.0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class Trial:
|
|
92
|
+
"""One parameter set and what it produced."""
|
|
93
|
+
|
|
94
|
+
index: int
|
|
95
|
+
params: dict[str, Any]
|
|
96
|
+
params_hash: str
|
|
97
|
+
n_obs: int
|
|
98
|
+
sharpe: float
|
|
99
|
+
sharpe_annualized: float
|
|
100
|
+
total_return_pct: float
|
|
101
|
+
failed: str | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class SweepResult:
|
|
106
|
+
"""Everything the sweep saw. The trial matrix is the irreplaceable part."""
|
|
107
|
+
|
|
108
|
+
trials: list[Trial]
|
|
109
|
+
matrix: npt.NDArray[np.float64] = field(repr=False) # (T observations, N configurations)
|
|
110
|
+
data_hash: str = ""
|
|
111
|
+
grid_keys: list[str] = field(default_factory=list)
|
|
112
|
+
store_params: bool = False
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def n_trials(self) -> int:
|
|
116
|
+
"""Derived, never supplied. This is the whole point of the module."""
|
|
117
|
+
return len(self.trials)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def best(self) -> Trial:
|
|
121
|
+
return max((t for t in self.trials if t.failed is None), key=lambda t: t.sharpe)
|
|
122
|
+
|
|
123
|
+
def verdict(self, *, risk_free_rate: float = 0.0) -> dict[str, Any]:
|
|
124
|
+
"""Deflate the best result for the search that actually produced it.
|
|
125
|
+
|
|
126
|
+
PBO is reported alongside but is NOT the gate. It answers a different
|
|
127
|
+
question, "was the choice AMONG configurations informative?", and
|
|
128
|
+
returns roughly 0.5 for genuinely near-tied top configs even when the
|
|
129
|
+
underlying edge is real. Measured on our own null/edge fixture, DSR
|
|
130
|
+
discriminates cleanly and PBO does not; treating them as two readings of
|
|
131
|
+
the same thing would be a mistake.
|
|
132
|
+
"""
|
|
133
|
+
best = self.best
|
|
134
|
+
col = self.matrix[:, best.index]
|
|
135
|
+
dsr = deflated_sharpe(col.tolist(), n_trials=self.n_trials)
|
|
136
|
+
out: dict[str, Any] = {
|
|
137
|
+
"n_trials": self.n_trials,
|
|
138
|
+
"n_trials_source": "derived_from_grid",
|
|
139
|
+
"best_trial_index": best.index,
|
|
140
|
+
"best_params_hash": best.params_hash,
|
|
141
|
+
"data_hash": self.data_hash,
|
|
142
|
+
"deflated_sharpe": dsr.get("deflated_sharpe"),
|
|
143
|
+
"verdict": dsr.get("verdict"),
|
|
144
|
+
"psr_vs_zero": dsr.get("psr_vs_zero"),
|
|
145
|
+
"sr0_expected_max": dsr.get("sr0_expected_max"),
|
|
146
|
+
"performance": performance_report(col.tolist(), risk_free_rate=risk_free_rate),
|
|
147
|
+
}
|
|
148
|
+
if self.store_params:
|
|
149
|
+
out["best_params"] = best.params
|
|
150
|
+
|
|
151
|
+
# Minimum track record length: how long this record must be before the
|
|
152
|
+
# result is distinguishable from luck. Bites universally, and converts
|
|
153
|
+
# into a dated obligation rather than a pass/fail insult.
|
|
154
|
+
try:
|
|
155
|
+
out["min_track_record_length"] = min_track_record_length(col.tolist())
|
|
156
|
+
except Exception: # noqa: BLE001 - never fail a verdict on an optional figure
|
|
157
|
+
out["min_track_record_length"] = None
|
|
158
|
+
|
|
159
|
+
# PBO needs at least two configurations; a single-point "sweep" cannot
|
|
160
|
+
# say anything about selection, and should say so rather than emit a
|
|
161
|
+
# number that looks like an answer.
|
|
162
|
+
if self.matrix.shape[1] >= 2:
|
|
163
|
+
out["selection"] = {
|
|
164
|
+
"question": "was the choice among configurations informative?",
|
|
165
|
+
"not_a_verdict_on_the_edge": True,
|
|
166
|
+
**pbo_cscv(self.matrix.tolist()),
|
|
167
|
+
}
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
def save(
|
|
171
|
+
self, path: str | Path = "study.json", *, label: str = "", data_description: str = "", notes: str = ""
|
|
172
|
+
) -> Path:
|
|
173
|
+
"""Write the study to disk. Local file, no account, no upload."""
|
|
174
|
+
from ..study import Study
|
|
175
|
+
from ..study import save as _save
|
|
176
|
+
|
|
177
|
+
study = Study.from_sweep(self, label=label, data_description=data_description, notes=notes)
|
|
178
|
+
return _save(study, path)
|
|
179
|
+
|
|
180
|
+
def surface(self) -> dict[str, Any]:
|
|
181
|
+
"""The neighbourhood: plateau or knife edge.
|
|
182
|
+
|
|
183
|
+
The payoff of running a grid, and the reason this is a coaching output
|
|
184
|
+
rather than a refereeing one, a researcher ends the session knowing
|
|
185
|
+
where to re-centre, not merely that their number was flattered.
|
|
186
|
+
"""
|
|
187
|
+
ok = [t for t in self.trials if t.failed is None]
|
|
188
|
+
if not ok:
|
|
189
|
+
return {"shape": "empty", "n_ok": 0, "n_failed": len(self.trials)}
|
|
190
|
+
|
|
191
|
+
sharpes = np.array([t.sharpe for t in ok], dtype=float)
|
|
192
|
+
best = float(sharpes.max())
|
|
193
|
+
median = float(np.median(sharpes))
|
|
194
|
+
# Share of the grid that holds up near the best result. A broad plateau
|
|
195
|
+
# is robustness; one spike is a result fitted to its own parameters.
|
|
196
|
+
near = float((sharpes >= best * 0.8).mean()) if best > 0 else 0.0
|
|
197
|
+
shape = "plateau" if near >= 0.30 else "ridge" if near >= 0.10 else "knife_edge"
|
|
198
|
+
|
|
199
|
+
# Where the robust region sits, per parameter, the actionable half.
|
|
200
|
+
centre: dict[str, Any] = {}
|
|
201
|
+
if self.store_params:
|
|
202
|
+
strong = [t for t in ok if best > 0 and t.sharpe >= best * 0.8]
|
|
203
|
+
for k in self.grid_keys:
|
|
204
|
+
vals = [t.params.get(k) for t in strong]
|
|
205
|
+
numeric = [v for v in vals if isinstance(v, (int, float)) and not isinstance(v, bool)]
|
|
206
|
+
if numeric:
|
|
207
|
+
centre[k] = {"median": float(np.median(numeric)), "range": [min(numeric), max(numeric)]}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"shape": shape,
|
|
211
|
+
"n_ok": len(ok),
|
|
212
|
+
"n_failed": len(self.trials) - len(ok),
|
|
213
|
+
"best_sharpe": round(best, 4),
|
|
214
|
+
"median_sharpe": round(median, 4),
|
|
215
|
+
"share_within_20pct_of_best": round(near, 4),
|
|
216
|
+
"plateau_centre": centre or None,
|
|
217
|
+
"reading": {
|
|
218
|
+
"plateau": "A broad region performs. The result does not depend on the exact parameters.",
|
|
219
|
+
"ridge": "A narrow region performs. Sensitive to the parameters; treat with care.",
|
|
220
|
+
"knife_edge": "One configuration performs and its neighbours do not. "
|
|
221
|
+
"Usually a fitted result.",
|
|
222
|
+
}[shape],
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def sweep(
|
|
227
|
+
backtest_fn: BacktestFn,
|
|
228
|
+
grid: Mapping[str, Iterable[Any]],
|
|
229
|
+
*,
|
|
230
|
+
data: Any = None,
|
|
231
|
+
store_params: bool = False,
|
|
232
|
+
on_error: str = "record",
|
|
233
|
+
) -> SweepResult:
|
|
234
|
+
"""Run `backtest_fn` once per combination in `grid`.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
backtest_fn: YOUR backtest. Called as ``backtest_fn(data=data, **params)``
|
|
238
|
+
when `data` is given, otherwise ``backtest_fn(**params)``. Must
|
|
239
|
+
return a sequence of per-period returns.
|
|
240
|
+
grid: parameter name -> values to try. The cartesian product is the
|
|
241
|
+
search, and its length is the trial count.
|
|
242
|
+
data: passed through untouched. Only hashed, never inspected or stored.
|
|
243
|
+
store_params: keep the parameter values in the result. OFF by default.
|
|
244
|
+
THE GRID IS OFTEN BIGGER IP THAN THE RETURN SERIES: "it uploads my
|
|
245
|
+
parameter search" ends a conversation faster than "it uploads my
|
|
246
|
+
returns". Hashes are always kept, so runs remain comparable without
|
|
247
|
+
the values leaving.
|
|
248
|
+
on_error: "record" marks a failing combination and continues (the
|
|
249
|
+
default, because one bad corner of a grid should not lose the
|
|
250
|
+
other ninety-nine results); "raise" propagates.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
SweepResult, holding the full (T x N) trial matrix that PBO needs and
|
|
254
|
+
that a single-point run structurally cannot produce.
|
|
255
|
+
"""
|
|
256
|
+
combos = _expand(grid)
|
|
257
|
+
if not combos:
|
|
258
|
+
raise ValueError("grid is empty: nothing to sweep")
|
|
259
|
+
|
|
260
|
+
data_hash = _hash_data(data) if data is not None else ""
|
|
261
|
+
trials: list[Trial] = []
|
|
262
|
+
columns: list[npt.NDArray[np.float64]] = []
|
|
263
|
+
|
|
264
|
+
for i, params in enumerate(combos):
|
|
265
|
+
try:
|
|
266
|
+
raw = backtest_fn(data=data, **params) if data is not None else backtest_fn(**params)
|
|
267
|
+
r = np.asarray(list(raw), dtype=float)
|
|
268
|
+
if r.ndim != 1 or r.size == 0:
|
|
269
|
+
raise ValueError(f"backtest_fn returned shape {r.shape}; expected a 1-D return series")
|
|
270
|
+
if not np.all(np.isfinite(r)):
|
|
271
|
+
r = np.nan_to_num(r, nan=0.0, posinf=0.0, neginf=0.0)
|
|
272
|
+
except Exception as exc: # noqa: BLE001
|
|
273
|
+
if on_error == "raise":
|
|
274
|
+
raise
|
|
275
|
+
trials.append(
|
|
276
|
+
Trial(
|
|
277
|
+
i,
|
|
278
|
+
dict(params),
|
|
279
|
+
_hash_params(params),
|
|
280
|
+
0,
|
|
281
|
+
0.0,
|
|
282
|
+
0.0,
|
|
283
|
+
0.0,
|
|
284
|
+
failed=f"{type(exc).__name__}: {exc}",
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
sr = _sharpe(r)
|
|
290
|
+
trials.append(
|
|
291
|
+
Trial(
|
|
292
|
+
index=i,
|
|
293
|
+
params=dict(params),
|
|
294
|
+
params_hash=_hash_params(params),
|
|
295
|
+
n_obs=int(r.size),
|
|
296
|
+
sharpe=round(sr, 6),
|
|
297
|
+
sharpe_annualized=round(sr * math.sqrt(252), 4),
|
|
298
|
+
total_return_pct=round((float(np.prod(1 + r)) - 1) * 100, 4),
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
columns.append(r)
|
|
302
|
+
|
|
303
|
+
if not columns:
|
|
304
|
+
raise RuntimeError(
|
|
305
|
+
f"every one of the {len(combos)} combinations failed. First error: {trials[0].failed}"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Ragged output means the configurations are not comparable, and silently
|
|
309
|
+
# truncating would produce a PBO over series that do not line up in time.
|
|
310
|
+
lengths = {c.size for c in columns}
|
|
311
|
+
if len(lengths) > 1:
|
|
312
|
+
n = min(lengths)
|
|
313
|
+
columns = [c[-n:] for c in columns]
|
|
314
|
+
|
|
315
|
+
return SweepResult(
|
|
316
|
+
trials=trials,
|
|
317
|
+
matrix=np.column_stack(columns),
|
|
318
|
+
data_hash=data_hash,
|
|
319
|
+
grid_keys=sorted(grid),
|
|
320
|
+
store_params=store_params,
|
|
321
|
+
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alphaengine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Validated research tooling for investment strategies: deflation, overfitting detection, and honest trial counts.
|
|
5
|
+
Project-URL: Homepage, https://github.com/quantOSC/alphaengine
|
|
6
|
+
Project-URL: Documentation, https://github.com/quantOSC/alphaengine#readme
|
|
7
|
+
Project-URL: Source, https://github.com/quantOSC/alphaengine
|
|
8
|
+
Project-URL: Issues, https://github.com/quantOSC/alphaengine/issues
|
|
9
|
+
Author: QuantOS
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: backtesting,deflated-sharpe,overfitting,quantitative-finance,research
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: numpy>=1.24
|
|
26
|
+
Requires-Dist: scipy>=1.10
|
|
27
|
+
Provides-Extra: agents
|
|
28
|
+
Requires-Dist: anthropic>=0.40; extra == 'agents'
|
|
29
|
+
Provides-Extra: connectors
|
|
30
|
+
Requires-Dist: httpx>=0.27; extra == 'connectors'
|
|
31
|
+
Requires-Dist: pyarrow>=15.0; extra == 'connectors'
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: mypy==2.3.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: ruff==0.16.1; extra == 'dev'
|
|
37
|
+
Requires-Dist: statsmodels>=0.14; extra == 'dev'
|
|
38
|
+
Provides-Extra: factors
|
|
39
|
+
Requires-Dist: statsmodels>=0.14; extra == 'factors'
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
# AlphaEngine
|
|
43
|
+
|
|
44
|
+
Validated research tooling for investment strategies. Run a parameter search,
|
|
45
|
+
get back the shape of the result and an honest read on whether it survives the
|
|
46
|
+
number of things you tried.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install alphaengine
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from alphaengine import sweep
|
|
54
|
+
|
|
55
|
+
r = sweep(backtest_fn, {"fast": [5, 10, 20], "slow": [50, 100, 200]}, data=prices)
|
|
56
|
+
|
|
57
|
+
r.surface() # is the result a broad plateau or a single lucky configuration?
|
|
58
|
+
r.verdict() # deflated for the 9 trials that were actually run
|
|
59
|
+
r.save() # study.json, on your disk
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## What it does
|
|
63
|
+
|
|
64
|
+
**Runs your parameter grid.** `sweep()` calls your backtest function once per
|
|
65
|
+
combination. It does not backtest anything itself, so the engine you already
|
|
66
|
+
trust stays the engine you trust.
|
|
67
|
+
|
|
68
|
+
**Counts the trials for you.** The statistics that correct a Sharpe ratio for
|
|
69
|
+
multiple testing need to know how many variants were tested. That number is
|
|
70
|
+
almost never recorded, because nobody counts what they discarded. Running the
|
|
71
|
+
grid makes it `len(grid)`, so it never has to be asked for or asserted.
|
|
72
|
+
|
|
73
|
+
**Shows you the neighbourhood.** The output is whether your result sits on a
|
|
74
|
+
broad plateau or a knife edge, and where the robust region is centred. A single
|
|
75
|
+
spike surrounded by failures is a result fitted to its own parameters.
|
|
76
|
+
|
|
77
|
+
**Produces a portable study.** A JSON artifact holding what was tried, what came
|
|
78
|
+
back, and a content hash of the data it ran on. Readable in a text editor,
|
|
79
|
+
diffable, and versioned so it still parses in two years.
|
|
80
|
+
|
|
81
|
+
## What is in it
|
|
82
|
+
|
|
83
|
+
| Module | Contents |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `alphaengine.core` | deflated Sharpe, PSR, PBO via CSCV, CPCV, minimum track record length, performance and risk statistics |
|
|
86
|
+
| `alphaengine.sweep` | the grid runner and the sensitivity surface |
|
|
87
|
+
| `alphaengine.study` | the study artifact and its schema |
|
|
88
|
+
|
|
89
|
+
Two runtime dependencies, numpy and scipy, both already present in a typical
|
|
90
|
+
research environment. `import alphaengine` makes no network call and needs no
|
|
91
|
+
account. Factor decomposition and cointegration testing need statsmodels and
|
|
92
|
+
are available as `pip install 'alphaengine[factors]'`.
|
|
93
|
+
|
|
94
|
+
## Where this sits in QuantOS
|
|
95
|
+
|
|
96
|
+
AlphaEngine is the open research layer of the [QuantOS](https://github.com/quantOSC)
|
|
97
|
+
platform. It is the piece that runs on your machine, against your data, and it
|
|
98
|
+
is complete on its own: everything above works offline and forever, at no cost.
|
|
99
|
+
|
|
100
|
+
The QuantOS platform builds on it. Studies produced here can be persisted to a
|
|
101
|
+
firm's record, referenced when an idea becomes a position, and assembled into
|
|
102
|
+
the reports that go to an investment committee or an allocator. The library
|
|
103
|
+
computes; the platform remembers and reports. The two halves are separated so
|
|
104
|
+
that the part touching your data has no reason to phone home.
|
|
105
|
+
|
|
106
|
+
## The methods
|
|
107
|
+
|
|
108
|
+
Everything in `core` comes from the published literature. Nothing here is a
|
|
109
|
+
proprietary formula, which is deliberate: a referee whose reasoning you cannot
|
|
110
|
+
inspect is not a referee.
|
|
111
|
+
|
|
112
|
+
**Deflated Sharpe Ratio, Probabilistic Sharpe Ratio, minimum track record length**
|
|
113
|
+
Bailey, D. H., and López de Prado, M. (2012). "The Sharpe Ratio Efficient
|
|
114
|
+
Frontier." *Journal of Risk* 15(2), 3 to 44.
|
|
115
|
+
Bailey, D. H., and López de Prado, M. (2014). "The Deflated Sharpe Ratio:
|
|
116
|
+
Correcting for Selection Bias, Backtest Overfitting, and Non-Normality."
|
|
117
|
+
*Journal of Portfolio Management* 40(5), 94 to 107.
|
|
118
|
+
|
|
119
|
+
**Probability of Backtest Overfitting via CSCV**
|
|
120
|
+
Bailey, D. H., Borwein, J., López de Prado, M., and Zhu, Q. J. (2017). "The
|
|
121
|
+
Probability of Backtest Overfitting." *Journal of Computational Finance* 20(4),
|
|
122
|
+
39 to 69.
|
|
123
|
+
|
|
124
|
+
**Combinatorial purged cross-validation**
|
|
125
|
+
López de Prado, M. (2018). *Advances in Financial Machine Learning.* Wiley,
|
|
126
|
+
chapters 7 and 12.
|
|
127
|
+
|
|
128
|
+
**Multiple testing in asset pricing**
|
|
129
|
+
Harvey, C. R., Liu, Y., and Zhu, H. (2016). "... and the Cross-Section of
|
|
130
|
+
Expected Returns." *Review of Financial Studies* 29(1), 5 to 68.
|
|
131
|
+
Harvey, C. R., and Liu, Y. (2015). "Backtesting." *Journal of Portfolio
|
|
132
|
+
Management* 42(1), 13 to 28.
|
|
133
|
+
|
|
134
|
+
**Downside deviation**
|
|
135
|
+
Sortino, F. A., and Price, L. N. (1994). "Performance Measurement in a Downside
|
|
136
|
+
Risk Framework." *Journal of Investing* 3(3), 59 to 64.
|
|
137
|
+
|
|
138
|
+
**Factor regression standard errors** (in the `factors` extra)
|
|
139
|
+
Newey, W. K., and West, K. D. (1987). "A Simple, Positive Semi-Definite,
|
|
140
|
+
Heteroskedasticity and Autocorrelation Consistent Covariance Matrix."
|
|
141
|
+
*Econometrica* 55(3), 703 to 708.
|
|
142
|
+
|
|
143
|
+
**Unit root testing for cointegration** (in the `factors` extra)
|
|
144
|
+
Dickey, D. A., and Fuller, W. A. (1979). "Distribution of the Estimators for
|
|
145
|
+
Autoregressive Time Series with a Unit Root." *Journal of the American
|
|
146
|
+
Statistical Association* 74(366), 427 to 431.
|
|
147
|
+
|
|
148
|
+
## Reproducibility
|
|
149
|
+
|
|
150
|
+
The values these functions return are treated as a public contract. A study
|
|
151
|
+
written today has to reproduce in two years, so a change to a computed value is
|
|
152
|
+
a breaking change requiring a major version bump even when the signature is
|
|
153
|
+
unchanged. CI fails if a pinned value moves.
|
|
154
|
+
|
|
155
|
+
## Licence
|
|
156
|
+
|
|
157
|
+
Apache-2.0. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
alphaengine/__init__.py,sha256=Xx64Qohlj9rNdzIpqpA12wXYhHfktMnergnpG1SD-AM,1954
|
|
2
|
+
alphaengine/_version.py,sha256=XAZ3PfMXxfgHsM8709CVeHw2zdqPFtgcuFRTKh8VvlI,651
|
|
3
|
+
alphaengine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
alphaengine/core/__init__.py,sha256=2rrUJrvTp6GElFeGVQSk0wkszcw2JMTQFyxryVMVJpM,2312
|
|
5
|
+
alphaengine/core/backtest.py,sha256=25bjrygUMhPc4Fg1dCUqcCQah92KQkPm3PedOuQGsXg,28609
|
|
6
|
+
alphaengine/core/factors.py,sha256=uR0seow6x3_Vshp-MkyRQ7WXZB144rV0Ik5Hh15A378,5291
|
|
7
|
+
alphaengine/core/pairs.py,sha256=F3DHM3krXJKZQlK1NMaq1xG9_Td27RyHO3_C9w9Ve3k,20170
|
|
8
|
+
alphaengine/core/performance.py,sha256=HajHtKpJHGVPHlsz70XnsCKvru-swpecU4s9dXJOsgE,4401
|
|
9
|
+
alphaengine/core/risk.py,sha256=xrnfcWE-95LQYDSN9unnjMRGUD0mZHC3AXh6dg459jw,5023
|
|
10
|
+
alphaengine/core/technical.py,sha256=FCK1Hv_yaBjGgOQO6vW9DC4iyJZGww9ST3MUPZ58WWU,7699
|
|
11
|
+
alphaengine/core/validation.py,sha256=cNkNfcdCsXQxgxMM0WDemmVnO2jubfQHcc9Nq21mOGs,14841
|
|
12
|
+
alphaengine/study/__init__.py,sha256=YWR8049WTzPbYvG-IYnj6IVsx_P0ceYKOXYVzzLc3mw,1480
|
|
13
|
+
alphaengine/study/schema.py,sha256=BMKzp11-4SRlu23qw7EqYKrMkacvHjE9orzlcKFv4K8,6381
|
|
14
|
+
alphaengine/sweep/__init__.py,sha256=TQ1w5v6Lb7_CNOC4NJC-Z64jWlUM5q7R-g0agCqNFoQ,803
|
|
15
|
+
alphaengine/sweep/runner.py,sha256=09T_bO4gl22ksIKJfYWPWzvFuM3waXuEx6XxEQpIzPI,13037
|
|
16
|
+
alphaengine-0.1.0.dist-info/METADATA,sha256=5WRceP3yRJrbBspEsJ16Aq6DhYUdWfSj8Lq3RtWrcgM,6817
|
|
17
|
+
alphaengine-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
18
|
+
alphaengine-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
19
|
+
alphaengine-0.1.0.dist-info/RECORD,,
|