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.
Files changed (37) hide show
  1. {alphaengine-0.1.2 → alphaengine-0.2.0}/CHANGELOG.md +90 -0
  2. {alphaengine-0.1.2 → alphaengine-0.2.0}/PKG-INFO +125 -3
  3. {alphaengine-0.1.2 → alphaengine-0.2.0}/README.md +124 -2
  4. alphaengine-0.2.0/examples/momentum.py +105 -0
  5. {alphaengine-0.1.2 → alphaengine-0.2.0}/pyproject.toml +13 -0
  6. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/_version.py +1 -1
  7. alphaengine-0.2.0/src/alphaengine/cli.py +506 -0
  8. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/client/__init__.py +4 -1
  9. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/client/executor.py +94 -1
  10. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/client/session.py +44 -1
  11. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/backtest.py +57 -10
  12. alphaengine-0.2.0/tests/test_cli.py +187 -0
  13. {alphaengine-0.1.2 → alphaengine-0.2.0}/tests/test_client.py +112 -1
  14. {alphaengine-0.1.2 → alphaengine-0.2.0}/.github/workflows/ci.yml +0 -0
  15. {alphaengine-0.1.2 → alphaengine-0.2.0}/.github/workflows/publish.yml +0 -0
  16. {alphaengine-0.1.2 → alphaengine-0.2.0}/.gitignore +0 -0
  17. {alphaengine-0.1.2 → alphaengine-0.2.0}/LICENSE +0 -0
  18. {alphaengine-0.1.2 → alphaengine-0.2.0}/SECURITY.md +0 -0
  19. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/__init__.py +0 -0
  20. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/agent/__init__.py +0 -0
  21. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/agent/driver.py +0 -0
  22. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/__init__.py +0 -0
  23. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/factors.py +0 -0
  24. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/pairs.py +0 -0
  25. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/performance.py +0 -0
  26. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/risk.py +0 -0
  27. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/technical.py +0 -0
  28. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/core/validation.py +0 -0
  29. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/py.typed +0 -0
  30. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/study/__init__.py +0 -0
  31. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/study/report.py +0 -0
  32. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/study/schema.py +0 -0
  33. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/sweep/__init__.py +0 -0
  34. {alphaengine-0.1.2 → alphaengine-0.2.0}/src/alphaengine/sweep/runner.py +0 -0
  35. {alphaengine-0.1.2 → alphaengine-0.2.0}/tests/test_goldens.py +0 -0
  36. {alphaengine-0.1.2 → alphaengine-0.2.0}/tests/test_smoke.py +0 -0
  37. {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.1.2
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 major version bump even when the signature is
180
- unchanged. CI fails if a pinned value moves.
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 major version bump even when the signature is
139
- unchanged. CI fails if a pinned value moves.
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
@@ -12,4 +12,4 @@ While the leading digit is 0, the MINOR position carries that rule: 0.1 -> 0.2
12
12
  is what a changed figure costs. The API may still move underneath it.
13
13
  """
14
14
 
15
- __version__ = "0.1.2"
15
+ __version__ = "0.2.0"