alphaengine 0.1.2__tar.gz → 0.2.0__tar.gz
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-0.1.2 → alphaengine-0.2.0}/CHANGELOG.md +90 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/PKG-INFO +125 -3
- {alphaengine-0.1.2 → alphaengine-0.2.0}/README.md +124 -2
- alphaengine-0.2.0/examples/momentum.py +105 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/pyproject.toml +13 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/_version.py +1 -1
- alphaengine-0.2.0/src/alphaengine/cli.py +506 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/client/__init__.py +4 -1
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/client/executor.py +94 -1
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/client/session.py +44 -1
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/backtest.py +57 -10
- alphaengine-0.2.0/tests/test_cli.py +187 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/tests/test_client.py +112 -1
- {alphaengine-0.1.2 → alphaengine-0.2.0}/.github/workflows/ci.yml +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/.github/workflows/publish.yml +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/.gitignore +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/LICENSE +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/SECURITY.md +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/__init__.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/agent/__init__.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/agent/driver.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/__init__.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/factors.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/pairs.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/performance.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/risk.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/technical.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/validation.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/py.typed +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/study/__init__.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/study/report.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/study/schema.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/sweep/__init__.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/sweep/runner.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/tests/test_goldens.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/tests/test_smoke.py +0 -0
- {alphaengine-0.1.2 → alphaengine-0.2.0}/tests/test_sweep.py +0 -0
|
@@ -7,6 +7,96 @@ minor bump.
|
|
|
7
7
|
|
|
8
8
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
9
9
|
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
## [0.2.0] - 2026-08-01
|
|
13
|
+
|
|
14
|
+
**A COMPUTED VALUE CHANGED.** `score_backtest` can return a different `verdict`
|
|
15
|
+
and a different `n_trials` for the same inputs. A study saved by 0.1.x whose
|
|
16
|
+
scoring omitted `n_trials` will not reproduce here, deliberately — that is the
|
|
17
|
+
whole point of the change, and it is why this is a minor bump under the
|
|
18
|
+
versioning rule rather than a patch.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`alphaengine`, a terminal entry point.** The harness was complete on both
|
|
23
|
+
sides and nothing could start a run: the server held the workflow, this
|
|
24
|
+
package held the executor, and wiring them together was a Python script the
|
|
25
|
+
user had to write. `alphaengine run validate_study --project research.momentum`
|
|
26
|
+
is that script.
|
|
27
|
+
|
|
28
|
+
**Install it into your project venv, not with pipx.** `compute.*` ops execute
|
|
29
|
+
in-process against your own DataFrames, so tool isolation — the usual and
|
|
30
|
+
otherwise correct way to install a CLI — is the one thing that cannot work
|
|
31
|
+
here. You cannot have isolation and in-process data access, and the data not
|
|
32
|
+
moving is the point.
|
|
33
|
+
|
|
34
|
+
Your data and your simulator come from a PROJECT MODULE you write and we
|
|
35
|
+
import (`data`, `backtest_fn`), because that module already exists: it is the
|
|
36
|
+
notebook cell you were going to run anyway.
|
|
37
|
+
|
|
38
|
+
No new dependencies. Nothing to put a model key in.
|
|
39
|
+
|
|
40
|
+
- **`emit.study` and `record.*` handlers** in `StepExecutor`. They were
|
|
41
|
+
vocabulary strings the server could issue and the client could not execute, so
|
|
42
|
+
a run that reached one could never produce the artifact it existed to produce.
|
|
43
|
+
`emit.study` takes the trial count FROM THE SWEEP THAT RAN, never from the
|
|
44
|
+
directive — a count arriving over the wire is one somebody upstream could have
|
|
45
|
+
chosen.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **A step that cannot succeed no longer spins two hundred times.** The server
|
|
50
|
+
re-offers a failed step, which is right for a transient failure and
|
|
51
|
+
catastrophic for a permanent one: an op with no handler failed identically
|
|
52
|
+
every time, and `drive()` exhausted `max_steps` before raising an error that
|
|
53
|
+
named the wrong problem. Two identical failures now end the run with the op
|
|
54
|
+
and the reason.
|
|
55
|
+
|
|
56
|
+
- **Separators no longer arrive as `�` on a Windows console.** The CLI uses `·`
|
|
57
|
+
and `→`, and `sys.stdout` there defaults to the console's legacy code page:
|
|
58
|
+
cp437 cannot encode either, and cp1252 encodes the mid-dot to a byte that
|
|
59
|
+
anything reading the log as UTF-8 renders as a replacement character.
|
|
60
|
+
`alphaengine run validate_study` printed `validate_study � https://...` on a
|
|
61
|
+
stock install.
|
|
62
|
+
|
|
63
|
+
The stream is now asked once what it can encode and the glyphs fall back to
|
|
64
|
+
ASCII when the answer is no — the same discipline already applied to colour,
|
|
65
|
+
which is suppressed when stdout is not a TTY. Forcing UTF-8 onto the stream
|
|
66
|
+
would override a choice the terminal made and trade one mojibake for another.
|
|
67
|
+
A separator is decoration; the words either side carry the meaning.
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- **`score_backtest(n_trials=...)` no longer defaults to 1. It defaults to
|
|
72
|
+
`None`, meaning NOT RECORDED, and an unrecorded count can no longer reach an
|
|
73
|
+
`edge` verdict.**
|
|
74
|
+
|
|
75
|
+
The old signature made omission the most generous answer arithmetic can
|
|
76
|
+
produce: no deflation at all, a fully populated `deflated_sharpe`, and — since
|
|
77
|
+
the moat only ever checked that the DSR was populated — the single cheapest
|
|
78
|
+
route to `edge`. The docstring advertising deflation as the overfitting moat
|
|
79
|
+
and the signature quietly undermining it shipped in the same function.
|
|
80
|
+
|
|
81
|
+
A deflated Sharpe is a ratio and `n_trials` is its denominator. Deflating by a
|
|
82
|
+
denominator nobody wrote down does not produce a weaker claim, it produces a
|
|
83
|
+
claim about nothing. So:
|
|
84
|
+
|
|
85
|
+
- `n_trials` comes back `null` rather than `1` when it was not supplied. "Not
|
|
86
|
+
recorded" and "I searched one configuration" are different facts and the
|
|
87
|
+
response no longer conflates them.
|
|
88
|
+
- `n_trials_source` is reported beside it — one of `derived_from_grid`,
|
|
89
|
+
`asserted`, `not_recorded`. A count that was COUNTED and a count that was
|
|
90
|
+
CLAIMED are different claims and every figure downstream rests on which.
|
|
91
|
+
- The DSR is still computed at an effective denominator of 1 and returned,
|
|
92
|
+
because the undeflated number is informative. It simply cannot clear a gate
|
|
93
|
+
alone.
|
|
94
|
+
|
|
95
|
+
**`sweep()` is unaffected in every respect.** It derives `n_trials` from the
|
|
96
|
+
grid as a property with no setter, so its source has always been
|
|
97
|
+
`derived_from_grid` and its figures are unchanged. If you were already passing
|
|
98
|
+
a trial count, nothing about your results moves either.
|
|
99
|
+
|
|
10
100
|
## [0.1.2] - 2026-07-31
|
|
11
101
|
|
|
12
102
|
**No computed value changed.** Identical figures to 0.1.0 and 0.1.1.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alphaengine
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Validated research tooling for investment strategies: deflation, overfitting detection, and honest trial counts.
|
|
5
5
|
Project-URL: Homepage, https://github.com/quantOSC/alphaengine
|
|
6
6
|
Project-URL: Documentation, https://github.com/quantOSC/alphaengine#readme
|
|
@@ -59,6 +59,62 @@ r.verdict() # deflated for the 9 trials that were actually run
|
|
|
59
59
|
r.save() # study.json, on your disk
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
Everything above runs offline, with no account and no key.
|
|
63
|
+
|
|
64
|
+
## Try it without writing anything
|
|
65
|
+
|
|
66
|
+
The repo ships a runnable project module, so you can see the whole offline half
|
|
67
|
+
work before deciding whether any of this is for you:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git clone https://github.com/quantOSC/alphaengine && cd alphaengine
|
|
71
|
+
pip install -e .
|
|
72
|
+
python -m examples.momentum
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
trials 9 (derived_from_grid)
|
|
77
|
+
verdict marginal
|
|
78
|
+
surface ridge
|
|
79
|
+
dsr 0.6352
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
That is a moving-average crossover on **synthetic prices from a fixed seed** —
|
|
83
|
+
no download, no data licence, same numbers on every machine. It is a
|
|
84
|
+
demonstration of the wiring, not a strategy. A crossover on a random walk has no
|
|
85
|
+
edge, and the verdict says so rather than flattering it. **That is the example
|
|
86
|
+
working, not failing.**
|
|
87
|
+
|
|
88
|
+
## Writing your own `backtest_fn`
|
|
89
|
+
|
|
90
|
+
Two rules, both easy to get wrong the first time, and the reason the example
|
|
91
|
+
above exists to copy:
|
|
92
|
+
|
|
93
|
+
**Return a bare 1-D return series.** Not a dict, not a stats object — the
|
|
94
|
+
per-period returns themselves. `sweep` does `np.asarray(list(raw))`, so a dict
|
|
95
|
+
of results iterates its *keys* and fails on the first string.
|
|
96
|
+
|
|
97
|
+
**Return the same length for every combination.** PBO splits the trial matrix
|
|
98
|
+
into time blocks and compares configurations within each block, which only means
|
|
99
|
+
anything if they line up in time. Ragged output is refused rather than truncated,
|
|
100
|
+
because silently trimming produces a confident number over series that do not
|
|
101
|
+
correspond. In practice: pick a warm-up long enough for the slowest window in
|
|
102
|
+
your grid and start every configuration there.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
WARMUP = 200 # covers the slowest `slow` in the grid
|
|
106
|
+
|
|
107
|
+
def backtest_fn(*, data, fast, slow):
|
|
108
|
+
close = data["close"]
|
|
109
|
+
return [
|
|
110
|
+
(close[i + 1] - close[i]) / close[i] * (1 if sma(close, fast, i) > sma(close, slow, i) else 0)
|
|
111
|
+
for i in range(WARMUP, len(close) - 1)
|
|
112
|
+
]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`data` is whatever you want it to be — a DataFrame, a dict of series, an array.
|
|
116
|
+
The package never inspects it and it never leaves your machine.
|
|
117
|
+
|
|
62
118
|
## What it does
|
|
63
119
|
|
|
64
120
|
**Runs your parameter grid.** `sweep()` calls your backtest function once per
|
|
@@ -70,6 +126,13 @@ multiple testing need to know how many variants were tested. That number is
|
|
|
70
126
|
almost never recorded, because nobody counts what they discarded. Running the
|
|
71
127
|
grid makes it `len(grid)`, so it never has to be asked for or asserted.
|
|
72
128
|
|
|
129
|
+
**Refuses to flatter an unrecorded count.** Since 0.2.0, omitting `n_trials`
|
|
130
|
+
means `not_recorded` — not `1`. The trial count comes back `null`,
|
|
131
|
+
`n_trials_source` travels beside it, and **a verdict of `edge` is unreachable
|
|
132
|
+
without a recorded denominator.** A deflated Sharpe is a ratio; deflating by a
|
|
133
|
+
denominator nobody wrote down does not produce a weaker claim, it produces a
|
|
134
|
+
claim about nothing.
|
|
135
|
+
|
|
73
136
|
**Shows you the neighbourhood.** The output is whether your result sits on a
|
|
74
137
|
broad plateau or a knife edge, and where the robust region is centred. A single
|
|
75
138
|
spike surrounded by failures is a result fitted to its own parameters.
|
|
@@ -78,6 +141,53 @@ spike surrounded by failures is a result fitted to its own parameters.
|
|
|
78
141
|
back, and a content hash of the data it ran on. Readable in a text editor,
|
|
79
142
|
diffable, and versioned so it still parses in two years.
|
|
80
143
|
|
|
144
|
+
## Running a workflow from the terminal
|
|
145
|
+
|
|
146
|
+
The offline half above is complete on its own. A *workflow* adds a sequence —
|
|
147
|
+
what to run, in what order, and what stops the run — and that sequence lives on
|
|
148
|
+
a QuantOS workflow server, so this part needs an account.
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
alphaengine version # no account needed
|
|
152
|
+
alphaengine workflows # what your workspace offers
|
|
153
|
+
alphaengine run validate_study --project examples.momentum
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The run narrates itself, because a loop you cannot watch is a loop you cannot
|
|
157
|
+
trust. The server names an op, this machine executes it and hands back figures:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
validate_study · <your workflow server>
|
|
161
|
+
server → compute.sweep
|
|
162
|
+
local · done
|
|
163
|
+
server → compute.deflated_sharpe
|
|
164
|
+
local · done
|
|
165
|
+
server → Stop: the surface is a ridge, not a plateau
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`--project` names an ordinary module of yours exposing `data` and, if the
|
|
169
|
+
workflow sweeps, `backtest_fn`. Authenticate with a key from the portal:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
export QUANTOS_API_KEY=ae_live_... # QUANTOS_API_URL for self-hosted or VPC
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**A stop exits 0.** "This did not clear the bar" is the system working, not a
|
|
176
|
+
broken build — a non-zero exit there would make every CI pipeline treat an
|
|
177
|
+
honest refusal as a failure, which is exactly the pressure that gets honesty
|
|
178
|
+
controls switched off. Only a run that could not execute a step exits non-zero.
|
|
179
|
+
|
|
180
|
+
> **Install this into the venv you do research in — not with `pipx` or
|
|
181
|
+
> `uv tool install`.** The compute steps execute in-process against your own
|
|
182
|
+
> DataFrames, so tool isolation, which is normally the right way to install a
|
|
183
|
+
> CLI, is the one thing that cannot work here. You cannot have isolation and
|
|
184
|
+
> in-process data access, and your data not moving is the point.
|
|
185
|
+
|
|
186
|
+
**No LLM dependency and no key field.** `AgentDriver` takes a callable, so you
|
|
187
|
+
bring your own model by passing a function. There is nowhere in this tool to put
|
|
188
|
+
a model key, ours or yours, and that is how "runs under your own account" is
|
|
189
|
+
satisfied structurally rather than promised.
|
|
190
|
+
|
|
81
191
|
## What is in it
|
|
82
192
|
|
|
83
193
|
| Module | Contents |
|
|
@@ -85,6 +195,8 @@ diffable, and versioned so it still parses in two years.
|
|
|
85
195
|
| `alphaengine.core` | deflated Sharpe, PSR, PBO via CSCV, CPCV, minimum track record length, performance and risk statistics |
|
|
86
196
|
| `alphaengine.sweep` | the grid runner and the sensitivity surface |
|
|
87
197
|
| `alphaengine.study` | the study artifact and its schema |
|
|
198
|
+
| `alphaengine.client` | the workflow client and the step executor |
|
|
199
|
+
| `alphaengine.cli` | the `alphaengine` terminal entry point |
|
|
88
200
|
|
|
89
201
|
Two runtime dependencies, numpy and scipy, both already present in a typical
|
|
90
202
|
research environment. `import alphaengine` makes no network call and needs no
|
|
@@ -176,8 +288,18 @@ Statistical Association* 74(366), 427 to 431.
|
|
|
176
288
|
|
|
177
289
|
The values these functions return are treated as a public contract. A study
|
|
178
290
|
written today has to reproduce in two years, so a change to a computed value is
|
|
179
|
-
a breaking change requiring a
|
|
180
|
-
|
|
291
|
+
a breaking change requiring a version bump even when the signature is unchanged.
|
|
292
|
+
While the leading digit is 0 the minor position carries that rule — 0.1 → 0.2 is
|
|
293
|
+
what a changed figure costs — so every 0.2.x release produces identical numbers.
|
|
294
|
+
CI fails if a pinned value moves.
|
|
295
|
+
|
|
296
|
+
## Development
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
python -m venv .venv && .venv/bin/pip install -e '.[factors]' pytest ruff
|
|
300
|
+
.venv/bin/python -m pytest -q
|
|
301
|
+
.venv/bin/python -m ruff check .
|
|
302
|
+
```
|
|
181
303
|
|
|
182
304
|
## Licence
|
|
183
305
|
|
|
@@ -18,6 +18,62 @@ r.verdict() # deflated for the 9 trials that were actually run
|
|
|
18
18
|
r.save() # study.json, on your disk
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
Everything above runs offline, with no account and no key.
|
|
22
|
+
|
|
23
|
+
## Try it without writing anything
|
|
24
|
+
|
|
25
|
+
The repo ships a runnable project module, so you can see the whole offline half
|
|
26
|
+
work before deciding whether any of this is for you:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/quantOSC/alphaengine && cd alphaengine
|
|
30
|
+
pip install -e .
|
|
31
|
+
python -m examples.momentum
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
trials 9 (derived_from_grid)
|
|
36
|
+
verdict marginal
|
|
37
|
+
surface ridge
|
|
38
|
+
dsr 0.6352
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That is a moving-average crossover on **synthetic prices from a fixed seed** —
|
|
42
|
+
no download, no data licence, same numbers on every machine. It is a
|
|
43
|
+
demonstration of the wiring, not a strategy. A crossover on a random walk has no
|
|
44
|
+
edge, and the verdict says so rather than flattering it. **That is the example
|
|
45
|
+
working, not failing.**
|
|
46
|
+
|
|
47
|
+
## Writing your own `backtest_fn`
|
|
48
|
+
|
|
49
|
+
Two rules, both easy to get wrong the first time, and the reason the example
|
|
50
|
+
above exists to copy:
|
|
51
|
+
|
|
52
|
+
**Return a bare 1-D return series.** Not a dict, not a stats object — the
|
|
53
|
+
per-period returns themselves. `sweep` does `np.asarray(list(raw))`, so a dict
|
|
54
|
+
of results iterates its *keys* and fails on the first string.
|
|
55
|
+
|
|
56
|
+
**Return the same length for every combination.** PBO splits the trial matrix
|
|
57
|
+
into time blocks and compares configurations within each block, which only means
|
|
58
|
+
anything if they line up in time. Ragged output is refused rather than truncated,
|
|
59
|
+
because silently trimming produces a confident number over series that do not
|
|
60
|
+
correspond. In practice: pick a warm-up long enough for the slowest window in
|
|
61
|
+
your grid and start every configuration there.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
WARMUP = 200 # covers the slowest `slow` in the grid
|
|
65
|
+
|
|
66
|
+
def backtest_fn(*, data, fast, slow):
|
|
67
|
+
close = data["close"]
|
|
68
|
+
return [
|
|
69
|
+
(close[i + 1] - close[i]) / close[i] * (1 if sma(close, fast, i) > sma(close, slow, i) else 0)
|
|
70
|
+
for i in range(WARMUP, len(close) - 1)
|
|
71
|
+
]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`data` is whatever you want it to be — a DataFrame, a dict of series, an array.
|
|
75
|
+
The package never inspects it and it never leaves your machine.
|
|
76
|
+
|
|
21
77
|
## What it does
|
|
22
78
|
|
|
23
79
|
**Runs your parameter grid.** `sweep()` calls your backtest function once per
|
|
@@ -29,6 +85,13 @@ multiple testing need to know how many variants were tested. That number is
|
|
|
29
85
|
almost never recorded, because nobody counts what they discarded. Running the
|
|
30
86
|
grid makes it `len(grid)`, so it never has to be asked for or asserted.
|
|
31
87
|
|
|
88
|
+
**Refuses to flatter an unrecorded count.** Since 0.2.0, omitting `n_trials`
|
|
89
|
+
means `not_recorded` — not `1`. The trial count comes back `null`,
|
|
90
|
+
`n_trials_source` travels beside it, and **a verdict of `edge` is unreachable
|
|
91
|
+
without a recorded denominator.** A deflated Sharpe is a ratio; deflating by a
|
|
92
|
+
denominator nobody wrote down does not produce a weaker claim, it produces a
|
|
93
|
+
claim about nothing.
|
|
94
|
+
|
|
32
95
|
**Shows you the neighbourhood.** The output is whether your result sits on a
|
|
33
96
|
broad plateau or a knife edge, and where the robust region is centred. A single
|
|
34
97
|
spike surrounded by failures is a result fitted to its own parameters.
|
|
@@ -37,6 +100,53 @@ spike surrounded by failures is a result fitted to its own parameters.
|
|
|
37
100
|
back, and a content hash of the data it ran on. Readable in a text editor,
|
|
38
101
|
diffable, and versioned so it still parses in two years.
|
|
39
102
|
|
|
103
|
+
## Running a workflow from the terminal
|
|
104
|
+
|
|
105
|
+
The offline half above is complete on its own. A *workflow* adds a sequence —
|
|
106
|
+
what to run, in what order, and what stops the run — and that sequence lives on
|
|
107
|
+
a QuantOS workflow server, so this part needs an account.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
alphaengine version # no account needed
|
|
111
|
+
alphaengine workflows # what your workspace offers
|
|
112
|
+
alphaengine run validate_study --project examples.momentum
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The run narrates itself, because a loop you cannot watch is a loop you cannot
|
|
116
|
+
trust. The server names an op, this machine executes it and hands back figures:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
validate_study · <your workflow server>
|
|
120
|
+
server → compute.sweep
|
|
121
|
+
local · done
|
|
122
|
+
server → compute.deflated_sharpe
|
|
123
|
+
local · done
|
|
124
|
+
server → Stop: the surface is a ridge, not a plateau
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`--project` names an ordinary module of yours exposing `data` and, if the
|
|
128
|
+
workflow sweeps, `backtest_fn`. Authenticate with a key from the portal:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
export QUANTOS_API_KEY=ae_live_... # QUANTOS_API_URL for self-hosted or VPC
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**A stop exits 0.** "This did not clear the bar" is the system working, not a
|
|
135
|
+
broken build — a non-zero exit there would make every CI pipeline treat an
|
|
136
|
+
honest refusal as a failure, which is exactly the pressure that gets honesty
|
|
137
|
+
controls switched off. Only a run that could not execute a step exits non-zero.
|
|
138
|
+
|
|
139
|
+
> **Install this into the venv you do research in — not with `pipx` or
|
|
140
|
+
> `uv tool install`.** The compute steps execute in-process against your own
|
|
141
|
+
> DataFrames, so tool isolation, which is normally the right way to install a
|
|
142
|
+
> CLI, is the one thing that cannot work here. You cannot have isolation and
|
|
143
|
+
> in-process data access, and your data not moving is the point.
|
|
144
|
+
|
|
145
|
+
**No LLM dependency and no key field.** `AgentDriver` takes a callable, so you
|
|
146
|
+
bring your own model by passing a function. There is nowhere in this tool to put
|
|
147
|
+
a model key, ours or yours, and that is how "runs under your own account" is
|
|
148
|
+
satisfied structurally rather than promised.
|
|
149
|
+
|
|
40
150
|
## What is in it
|
|
41
151
|
|
|
42
152
|
| Module | Contents |
|
|
@@ -44,6 +154,8 @@ diffable, and versioned so it still parses in two years.
|
|
|
44
154
|
| `alphaengine.core` | deflated Sharpe, PSR, PBO via CSCV, CPCV, minimum track record length, performance and risk statistics |
|
|
45
155
|
| `alphaengine.sweep` | the grid runner and the sensitivity surface |
|
|
46
156
|
| `alphaengine.study` | the study artifact and its schema |
|
|
157
|
+
| `alphaengine.client` | the workflow client and the step executor |
|
|
158
|
+
| `alphaengine.cli` | the `alphaengine` terminal entry point |
|
|
47
159
|
|
|
48
160
|
Two runtime dependencies, numpy and scipy, both already present in a typical
|
|
49
161
|
research environment. `import alphaengine` makes no network call and needs no
|
|
@@ -135,8 +247,18 @@ Statistical Association* 74(366), 427 to 431.
|
|
|
135
247
|
|
|
136
248
|
The values these functions return are treated as a public contract. A study
|
|
137
249
|
written today has to reproduce in two years, so a change to a computed value is
|
|
138
|
-
a breaking change requiring a
|
|
139
|
-
|
|
250
|
+
a breaking change requiring a version bump even when the signature is unchanged.
|
|
251
|
+
While the leading digit is 0 the minor position carries that rule — 0.1 → 0.2 is
|
|
252
|
+
what a changed figure costs — so every 0.2.x release produces identical numbers.
|
|
253
|
+
CI fails if a pinned value moves.
|
|
254
|
+
|
|
255
|
+
## Development
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
python -m venv .venv && .venv/bin/pip install -e '.[factors]' pytest ruff
|
|
259
|
+
.venv/bin/python -m pytest -q
|
|
260
|
+
.venv/bin/python -m ruff check .
|
|
261
|
+
```
|
|
140
262
|
|
|
141
263
|
## Licence
|
|
142
264
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""A project module you can actually run, so the CLI has something to point at.
|
|
2
|
+
|
|
3
|
+
alphaengine run validate_study --project examples.momentum
|
|
4
|
+
|
|
5
|
+
A PROJECT MODULE IS NOT A CONFIG FILE. It is an ordinary Python module of yours
|
|
6
|
+
that exposes two names:
|
|
7
|
+
|
|
8
|
+
data whatever your backtest function takes — a DataFrame, a dict of
|
|
9
|
+
series, an array. The harness never inspects it and it never
|
|
10
|
+
leaves this machine.
|
|
11
|
+
backtest_fn your simulator, called once per parameter combination as
|
|
12
|
+
`backtest_fn(data=data, **params)`.
|
|
13
|
+
|
|
14
|
+
That is the whole contract. It is deliberately the shape of the notebook cell
|
|
15
|
+
you were going to write anyway, because the alternative — a YAML schema
|
|
16
|
+
describing your strategy — means reimplementing your research in our vocabulary
|
|
17
|
+
before you can find out whether it survives.
|
|
18
|
+
|
|
19
|
+
TWO THINGS `backtest_fn` MUST DO, both easy to get wrong the first time:
|
|
20
|
+
|
|
21
|
+
1. RETURN A BARE 1-D RETURN SERIES. Not a dict, not a stats object — the
|
|
22
|
+
per-period returns themselves. `sweep` does `np.asarray(list(raw))`, so a
|
|
23
|
+
dict of results iterates its KEYS and fails on the first string.
|
|
24
|
+
2. RETURN THE SAME LENGTH FOR EVERY COMBINATION. PBO splits the trial matrix
|
|
25
|
+
into time blocks and compares configurations within each block, which is
|
|
26
|
+
only meaningful if they line up in time. Ragged output is refused rather
|
|
27
|
+
than truncated, because silently trimming would produce a confident number
|
|
28
|
+
over series that do not correspond. That is why `WARMUP` below is a
|
|
29
|
+
constant covering the slowest window in the grid, instead of each
|
|
30
|
+
configuration starting wherever its own window happens to fill.
|
|
31
|
+
|
|
32
|
+
This one is a moving-average crossover on synthetic prices. The prices come from
|
|
33
|
+
a fixed seed rather than a download, so the example runs offline, gives the same
|
|
34
|
+
figures on every machine, and needs no data licence. It is a DEMONSTRATION OF
|
|
35
|
+
THE WIRING, not a strategy: a crossover on a random walk has no edge, and the
|
|
36
|
+
verdict at the end says so. That is the example working, not failing.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import random
|
|
42
|
+
|
|
43
|
+
# Long enough to cover the slowest window in the documented grid (slow=200).
|
|
44
|
+
# Every configuration starts here, so every return series is the same length.
|
|
45
|
+
WARMUP = 200
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _prices(n: int = 1200, seed: int = 7) -> list[float]:
|
|
49
|
+
"""A synthetic close series. Fixed seed: the example must reproduce."""
|
|
50
|
+
rng = random.Random(seed)
|
|
51
|
+
px, out = 100.0, []
|
|
52
|
+
for _ in range(n):
|
|
53
|
+
px *= 1.0 + rng.gauss(0.0004, 0.011)
|
|
54
|
+
out.append(round(px, 4))
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# What `--project examples.momentum` hands to the harness.
|
|
59
|
+
data = {"close": _prices()}
|
|
60
|
+
|
|
61
|
+
# The grid the README and the CLI examples use. Nine combinations, so a
|
|
62
|
+
# derived trial count of exactly 9 — a number you can check by hand.
|
|
63
|
+
GRID = {"fast": [5, 10, 20], "slow": [50, 100, 200]}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def backtest_fn(*, data, fast: int = 10, slow: int = 50) -> list[float]:
|
|
67
|
+
"""One moving-average crossover, long/flat, as a daily return series.
|
|
68
|
+
|
|
69
|
+
`fast >= slow` is a degenerate corner of the grid. It returns a flat series
|
|
70
|
+
of the right LENGTH rather than an empty one: a combination that fails is
|
|
71
|
+
still a combination that was tried, and dropping it would quietly shrink the
|
|
72
|
+
denominator every deflated figure downstream divides by.
|
|
73
|
+
"""
|
|
74
|
+
close = data["close"]
|
|
75
|
+
n = len(close)
|
|
76
|
+
|
|
77
|
+
def sma(k: int, i: int) -> float:
|
|
78
|
+
return sum(close[i - k + 1 : i + 1]) / k
|
|
79
|
+
|
|
80
|
+
if fast >= slow or slow > WARMUP:
|
|
81
|
+
return [0.0] * (n - WARMUP - 1)
|
|
82
|
+
|
|
83
|
+
out: list[float] = []
|
|
84
|
+
position = 0
|
|
85
|
+
for i in range(WARMUP, n - 1):
|
|
86
|
+
position = 1 if sma(fast, i) > sma(slow, i) else 0
|
|
87
|
+
step = (close[i + 1] - close[i]) / close[i]
|
|
88
|
+
out.append(round(step * position, 8))
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
# `python -m examples.momentum` — the offline half. No server, no account,
|
|
94
|
+
# no network. This is the whole package working on its own.
|
|
95
|
+
from alphaengine import sweep
|
|
96
|
+
|
|
97
|
+
result = sweep(backtest_fn, GRID, data=data)
|
|
98
|
+
verdict = result.verdict()
|
|
99
|
+
surface = result.surface()
|
|
100
|
+
|
|
101
|
+
print(f"trials {result.n_trials} ({verdict.get('n_trials_source')})")
|
|
102
|
+
print(f"verdict {verdict.get('verdict')}")
|
|
103
|
+
print(f"surface {surface.get('shape')}")
|
|
104
|
+
dsr = verdict.get("deflated_sharpe")
|
|
105
|
+
print(f"dsr {dsr if dsr is not None else 'not recorded'}")
|
|
@@ -42,6 +42,19 @@ dependencies = [
|
|
|
42
42
|
"scipy>=1.10",
|
|
43
43
|
]
|
|
44
44
|
|
|
45
|
+
# THE TERMINAL ENTRY POINT. Deliberately adds nothing new: argparse from the
|
|
46
|
+
# standard library, and the client's urllib transport. A CLI that dragged in a
|
|
47
|
+
# TUI framework would break the two-dependency promise above on behalf of people
|
|
48
|
+
# who may never run it.
|
|
49
|
+
#
|
|
50
|
+
# INSTALL THIS IN YOUR PROJECT VENV, NOT WITH pipx OR `uv tool install`.
|
|
51
|
+
# `compute.*` ops execute in-process against your own DataFrames, so tool
|
|
52
|
+
# isolation — the usual and otherwise correct way to install a CLI — is the one
|
|
53
|
+
# thing that cannot work here. You cannot have isolation and in-process data
|
|
54
|
+
# access, and the data not moving is the whole point.
|
|
55
|
+
[project.scripts]
|
|
56
|
+
alphaengine = "alphaengine.cli:main"
|
|
57
|
+
|
|
45
58
|
[project.optional-dependencies]
|
|
46
59
|
# Factor decomposition (OLS with HAC errors) and cointegration (ADF) need
|
|
47
60
|
# statsmodels, which pulls pandas and patsy behind it. Kept OUT of the core so
|