alphaengine 0.1.0__tar.gz → 0.1.2__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/CHANGELOG.md +108 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/PKG-INFO +28 -1
- {alphaengine-0.1.0 → alphaengine-0.1.2}/README.md +27 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/__init__.py +20 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/_version.py +1 -1
- alphaengine-0.1.2/src/alphaengine/agent/__init__.py +16 -0
- alphaengine-0.1.2/src/alphaengine/agent/driver.py +117 -0
- alphaengine-0.1.2/src/alphaengine/client/__init__.py +38 -0
- alphaengine-0.1.2/src/alphaengine/client/executor.py +236 -0
- alphaengine-0.1.2/src/alphaengine/client/session.py +237 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/study/__init__.py +21 -1
- alphaengine-0.1.2/src/alphaengine/study/report.py +180 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/study/schema.py +11 -0
- alphaengine-0.1.2/tests/test_client.py +295 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/tests/test_smoke.py +29 -0
- alphaengine-0.1.0/CHANGELOG.md +0 -46
- {alphaengine-0.1.0 → alphaengine-0.1.2}/.github/workflows/ci.yml +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/.github/workflows/publish.yml +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/.gitignore +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/LICENSE +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/SECURITY.md +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/pyproject.toml +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/__init__.py +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/backtest.py +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/factors.py +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/pairs.py +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/performance.py +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/risk.py +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/technical.py +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/validation.py +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/py.typed +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/sweep/__init__.py +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/sweep/runner.py +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/tests/test_goldens.py +0 -0
- {alphaengine-0.1.0 → alphaengine-0.1.2}/tests/test_sweep.py +0 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Every entry that changes a computed value says so in the first line, because a
|
|
4
|
+
saved study has to reproduce years after it was written. See `_version.py` for
|
|
5
|
+
the versioning rule: while the leading digit is 0, a changed figure costs a
|
|
6
|
+
minor bump.
|
|
7
|
+
|
|
8
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
9
|
+
|
|
10
|
+
## [0.1.2] - 2026-07-31
|
|
11
|
+
|
|
12
|
+
**No computed value changed.** Identical figures to 0.1.0 and 0.1.1.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- `report()` used `try`/`except`/`pass` to read an error body, which ruff's
|
|
17
|
+
SIM105 correctly objects to. It is `contextlib.suppress` now. The intent was
|
|
18
|
+
always total suppression: a server answering with HTML, an empty body or
|
|
19
|
+
nothing at all still has to produce a usable error rather than a second
|
|
20
|
+
exception raised on top of the first.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- `DEFAULT_BASE_URL` points at the live platform. 0.1.1 shipped with a
|
|
25
|
+
placeholder host, so every `report()` call that did not pass `base_url` or set
|
|
26
|
+
`QUANTOS_API_URL` resolved to a domain that does not answer. Reporting was
|
|
27
|
+
effectively unusable at its default in that release.
|
|
28
|
+
|
|
29
|
+
## [0.1.1] - 2026-07-31
|
|
30
|
+
|
|
31
|
+
**No computed value changed.** Every figure this version produces is identical
|
|
32
|
+
to 0.1.0; the goldens are untouched. A study saved by 0.1.0 reproduces here.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- `report()`, which sends a study to a QuantOS workspace. The offline half is
|
|
37
|
+
unchanged and always will be: `save()` still needs no account, no key and no
|
|
38
|
+
network. What it could not do was reach the person who has to act on the
|
|
39
|
+
work, so a run done in a notebook stayed in the notebook. This is that
|
|
40
|
+
crossing, and nothing else about the package assumes you will ever call it.
|
|
41
|
+
|
|
42
|
+
Available as `alphaengine.report(study, ...)` and as `Study.report()`. A key
|
|
43
|
+
comes from `QUANTOS_API_KEY` unless passed explicitly, because a key pasted
|
|
44
|
+
into a notebook is a key committed to git. `QUANTOS_API_URL` points it at a
|
|
45
|
+
self-hosted or VPC deployment.
|
|
46
|
+
|
|
47
|
+
- A client-side series guard on the reported payload, keyed on LENGTH rather
|
|
48
|
+
than field name, so renaming a key cannot smuggle a return series past it.
|
|
49
|
+
The same check runs on the server. The duplication is deliberate: a check
|
|
50
|
+
that runs only on the client is not a check, and one that runs only on the
|
|
51
|
+
server tells you too late and without naming the field.
|
|
52
|
+
|
|
53
|
+
What crosses is an explicit allowlist — the trial count and how it was
|
|
54
|
+
obtained, the data hash, the verdict, the surface, the performance figures. A
|
|
55
|
+
field added to `Study` later does not start leaving the machine because
|
|
56
|
+
somebody forgot to exclude it. `best_params` is absent on purpose: the grid
|
|
57
|
+
is frequently bigger intellectual property than the returns.
|
|
58
|
+
|
|
59
|
+
### Unchanged, and enforced
|
|
60
|
+
|
|
61
|
+
- `import alphaengine` still makes no network call and pulls in no HTTP client.
|
|
62
|
+
Reporting is the one thing here that touches a network, so it is the one
|
|
63
|
+
thing not imported until you ask for it — `report` resolves through a module
|
|
64
|
+
`__getattr__` rather than at import time. Two tests hold that line: one
|
|
65
|
+
asserts no HTTP client lands in `sys.modules`, the other that the top level
|
|
66
|
+
never drags in the client subpackage.
|
|
67
|
+
|
|
68
|
+
- Still two runtime dependencies. Reporting uses `urllib` from the standard
|
|
69
|
+
library rather than `requests`, because a third dependency would be a cost
|
|
70
|
+
paid by every user of the offline half, who did not ask for it.
|
|
71
|
+
|
|
72
|
+
## [0.1.0] - 2026-07-30
|
|
73
|
+
|
|
74
|
+
First public release. No figures changed, because there is nothing yet to
|
|
75
|
+
change them from.
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
|
|
79
|
+
- `sweep()`, which runs a parameter grid through the caller's own backtest
|
|
80
|
+
function and derives the trial count from the grid rather than accepting it as
|
|
81
|
+
an argument. The signature has no `n_trials` parameter and a test pins that
|
|
82
|
+
absence, since a supplied count makes the deflation self-reported.
|
|
83
|
+
- `SweepResult.verdict()`, reporting the deflated Sharpe ratio, PSR, minimum
|
|
84
|
+
track record length and a performance summary. Probability of backtest
|
|
85
|
+
overfitting is reported beside them under `selection` and is deliberately not
|
|
86
|
+
the gate: measured on a null-versus-edge fixture it does not discriminate
|
|
87
|
+
where the deflated Sharpe does, so gating on it would reject real results.
|
|
88
|
+
- `SweepResult.surface()`, classifying the parameter neighbourhood as a plateau,
|
|
89
|
+
a ridge or a knife edge, with the robust region per parameter when parameters
|
|
90
|
+
are being stored.
|
|
91
|
+
- The `Study` artifact and its versioned JSON schema. `load()` refuses an
|
|
92
|
+
unknown major version rather than half-parsing it, and tolerates unknown
|
|
93
|
+
fields within a known major so a newer writer stays readable by an older
|
|
94
|
+
build. A study holds derived figures and hashes, never a series.
|
|
95
|
+
- `alphaengine.core`: deflated Sharpe, PSR, PBO by CSCV, minimum track record
|
|
96
|
+
length, a performance report, value at risk and conditional value at risk,
|
|
97
|
+
factor decomposition, cointegration tests and a signal backtest. Each cites
|
|
98
|
+
its source in the docstring.
|
|
99
|
+
|
|
100
|
+
### Notes on defaults
|
|
101
|
+
|
|
102
|
+
- Parameter values are **not** stored unless `store_params=True`. A parameter
|
|
103
|
+
grid is frequently larger intellectual property than the return series it
|
|
104
|
+
produced. Hashes are always kept, so two runs stay comparable without the
|
|
105
|
+
values leaving the machine.
|
|
106
|
+
- Data identity is a content hash of the series, never a caller-supplied label,
|
|
107
|
+
so renaming an experiment does not detach it from its history.
|
|
108
|
+
- Importing the package makes no network call and requires no account.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alphaengine
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
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
|
|
@@ -91,6 +91,33 @@ research environment. `import alphaengine` makes no network call and needs no
|
|
|
91
91
|
account. Factor decomposition and cointegration testing need statsmodels and
|
|
92
92
|
are available as `pip install 'alphaengine[factors]'`.
|
|
93
93
|
|
|
94
|
+
## Getting a study to somebody else
|
|
95
|
+
|
|
96
|
+
`save()` writes to your disk and needs no account. When the work has to reach
|
|
97
|
+
the PM who will act on it, `report()` sends the study — and only the study.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import os
|
|
101
|
+
from alphaengine import Study, sweep
|
|
102
|
+
|
|
103
|
+
os.environ["QUANTOS_API_KEY"] = "ae_live_..." # created in the portal
|
|
104
|
+
|
|
105
|
+
r = sweep(backtest_fn, grid, data=prices)
|
|
106
|
+
r.save() # yours, on your disk, always
|
|
107
|
+
|
|
108
|
+
Study.from_sweep(r, label="momentum, 9 configs").report()
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
What crosses is an explicit allowlist: the trial count and how it was obtained,
|
|
112
|
+
a content hash of the data, the verdict, the shape of the neighbourhood, the
|
|
113
|
+
performance figures. Your returns, your prices and your parameter grid stay on
|
|
114
|
+
the machine, and a guard keyed on length rather than field name refuses to send
|
|
115
|
+
anything series-shaped whatever it is called.
|
|
116
|
+
|
|
117
|
+
Reporting is the only part of this package that touches a network, so it is the
|
|
118
|
+
only part that is not imported until you call it. `import alphaengine` still
|
|
119
|
+
makes no network call.
|
|
120
|
+
|
|
94
121
|
## Where this sits in QuantOS
|
|
95
122
|
|
|
96
123
|
AlphaEngine is the open research layer of the [QuantOS](https://github.com/quantOSC)
|
|
@@ -50,6 +50,33 @@ research environment. `import alphaengine` makes no network call and needs no
|
|
|
50
50
|
account. Factor decomposition and cointegration testing need statsmodels and
|
|
51
51
|
are available as `pip install 'alphaengine[factors]'`.
|
|
52
52
|
|
|
53
|
+
## Getting a study to somebody else
|
|
54
|
+
|
|
55
|
+
`save()` writes to your disk and needs no account. When the work has to reach
|
|
56
|
+
the PM who will act on it, `report()` sends the study — and only the study.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import os
|
|
60
|
+
from alphaengine import Study, sweep
|
|
61
|
+
|
|
62
|
+
os.environ["QUANTOS_API_KEY"] = "ae_live_..." # created in the portal
|
|
63
|
+
|
|
64
|
+
r = sweep(backtest_fn, grid, data=prices)
|
|
65
|
+
r.save() # yours, on your disk, always
|
|
66
|
+
|
|
67
|
+
Study.from_sweep(r, label="momentum, 9 configs").report()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
What crosses is an explicit allowlist: the trial count and how it was obtained,
|
|
71
|
+
a content hash of the data, the verdict, the shape of the neighbourhood, the
|
|
72
|
+
performance figures. Your returns, your prices and your parameter grid stay on
|
|
73
|
+
the machine, and a guard keyed on length rather than field name refuses to send
|
|
74
|
+
anything series-shaped whatever it is called.
|
|
75
|
+
|
|
76
|
+
Reporting is the only part of this package that touches a network, so it is the
|
|
77
|
+
only part that is not imported until you call it. `import alphaengine` still
|
|
78
|
+
makes no network call.
|
|
79
|
+
|
|
53
80
|
## Where this sits in QuantOS
|
|
54
81
|
|
|
55
82
|
AlphaEngine is the open research layer of the [QuantOS](https://github.com/quantOSC)
|
|
@@ -27,6 +27,8 @@ OFFLINE BY CONSTRUCTION
|
|
|
27
27
|
and your data never leaves the machine.
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
30
32
|
from ._version import __version__
|
|
31
33
|
from .study import SCHEMA_VERSION, Study, load, save
|
|
32
34
|
from .sweep import SweepResult, sweep
|
|
@@ -43,5 +45,23 @@ __all__ = [
|
|
|
43
45
|
"Study",
|
|
44
46
|
"save",
|
|
45
47
|
"load",
|
|
48
|
+
"report",
|
|
49
|
+
"ReportError",
|
|
46
50
|
"SCHEMA_VERSION",
|
|
47
51
|
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def __getattr__(name: str) -> Any: # PEP 562
|
|
55
|
+
"""`report` and `ReportError` load on first use.
|
|
56
|
+
|
|
57
|
+
Everything else in this package is offline by construction and imports
|
|
58
|
+
eagerly. Reporting is the one thing that touches a network, so it is the one
|
|
59
|
+
thing that is not imported until you ask for it — which is what keeps
|
|
60
|
+
`import alphaengine` free of networking code entirely.
|
|
61
|
+
"""
|
|
62
|
+
if name in ("report", "ReportError"):
|
|
63
|
+
import importlib
|
|
64
|
+
|
|
65
|
+
mod = importlib.import_module(".study.report", __name__)
|
|
66
|
+
return getattr(mod, name)
|
|
67
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Drive a workflow run with a model you supply.
|
|
2
|
+
|
|
3
|
+
from alphaengine.agent import AgentDriver
|
|
4
|
+
|
|
5
|
+
The agent's action space is the permitted set the server sent this turn, so it
|
|
6
|
+
cannot call an unoffered operation, skip a gate, proceed past a stop, or act
|
|
7
|
+
without a record. See `driver.py` for why each of those holds by construction.
|
|
8
|
+
|
|
9
|
+
No model dependency, no key handling, no inference call. `choose` is yours.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .driver import AgentDriver, Choice, RefusedChoice
|
|
15
|
+
|
|
16
|
+
__all__ = ["AgentDriver", "Choice", "RefusedChoice"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""The agent driver: a model chooses among the steps it was just offered.
|
|
2
|
+
|
|
3
|
+
from alphaengine.agent import AgentDriver
|
|
4
|
+
|
|
5
|
+
driver = AgentDriver(choose=my_model_fn) # your key, your model, your call
|
|
6
|
+
driver.drive(run)
|
|
7
|
+
|
|
8
|
+
WHAT BOUNDS THE AGENT, CONCRETELY
|
|
9
|
+
Its action space is the permitted set the server sent this turn. Not the
|
|
10
|
+
library's function list, not a tool catalogue, not anything it can name.
|
|
11
|
+
|
|
12
|
+
So it cannot call something that was not offered, cannot skip a gate (gates
|
|
13
|
+
are evaluated server side on the result), cannot proceed past a stop (no
|
|
14
|
+
next step is issued), and cannot act without a record (the only thing it can
|
|
15
|
+
do is execute steps, and every step writes).
|
|
16
|
+
|
|
17
|
+
That is true by construction rather than by policy, which is the sentence
|
|
18
|
+
worth putting in front of a compliance reviewer. This module is deliberately
|
|
19
|
+
small: the guarantee comes from what it CANNOT reach, so the code that could
|
|
20
|
+
weaken it is code that is not here.
|
|
21
|
+
|
|
22
|
+
YOUR MODEL, YOUR KEY, YOUR MACHINE
|
|
23
|
+
`choose` is a callable you supply. This package has no model dependency, no
|
|
24
|
+
API key handling, and makes no inference call. It never sees your
|
|
25
|
+
credentials because it never has a reason to.
|
|
26
|
+
|
|
27
|
+
THE ONE THING THE AGENT ACTUALLY DECIDES
|
|
28
|
+
On a selection="all" turn, nothing: the steps do not branch and all of them
|
|
29
|
+
run. On selection="any", it picks one. That is the real decision point, and
|
|
30
|
+
keeping it that narrow is what makes an agent-driven run as auditable as a
|
|
31
|
+
hand-driven one.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
from collections.abc import Callable
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
__all__ = ["AgentDriver", "Choice", "RefusedChoice"]
|
|
44
|
+
|
|
45
|
+
# Your model, given the permitted steps and what the run has produced so far,
|
|
46
|
+
# returns the index of the step to take. Nothing else: not a step it invented,
|
|
47
|
+
# not a parameter override, not an instruction.
|
|
48
|
+
Choice = Callable[[list[dict[str, Any]], dict[str, Any]], int]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RefusedChoice(RuntimeError):
|
|
52
|
+
"""The chooser returned something outside the permitted set.
|
|
53
|
+
|
|
54
|
+
Raised rather than clamped. Silently coercing an out-of-range choice to a
|
|
55
|
+
valid one would mean the run continues while the record says the agent chose
|
|
56
|
+
something it did not, and a trace that misreports the decision is worse than
|
|
57
|
+
a run that stops.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class AgentDriver:
|
|
63
|
+
"""Drives a run by asking `choose` which permitted step to take."""
|
|
64
|
+
|
|
65
|
+
choose: Choice
|
|
66
|
+
max_steps: int = 200
|
|
67
|
+
|
|
68
|
+
def drive(self, run: Any) -> Any:
|
|
69
|
+
"""Run until it stops or closes.
|
|
70
|
+
|
|
71
|
+
A stop returns normally. The agent is not given the chance to react to
|
|
72
|
+
one, because there is nothing to react to: no next step is issued, and
|
|
73
|
+
an agent that could argue with a gate would not be bounded by it.
|
|
74
|
+
"""
|
|
75
|
+
n = 0
|
|
76
|
+
while run.status == "open":
|
|
77
|
+
if n >= self.max_steps:
|
|
78
|
+
raise RuntimeError(f"run {run.run_id} exceeded {self.max_steps} steps")
|
|
79
|
+
|
|
80
|
+
if not run.permitted:
|
|
81
|
+
run.resume()
|
|
82
|
+
if run.status != "open" or not run.permitted:
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
if run.selection == "all":
|
|
86
|
+
# No decision to make: these do not branch, so all of them run.
|
|
87
|
+
# The agent is not consulted, which is correct rather than a
|
|
88
|
+
# shortcut. Asking it to "choose" among steps that all execute
|
|
89
|
+
# would invent a decision and then record it as one.
|
|
90
|
+
for step in list(run.permitted):
|
|
91
|
+
run.step(step)
|
|
92
|
+
n += 1
|
|
93
|
+
if run.status != "open":
|
|
94
|
+
return run
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
index = self._ask(run)
|
|
98
|
+
run.step(run.permitted[index])
|
|
99
|
+
n += 1
|
|
100
|
+
return run
|
|
101
|
+
|
|
102
|
+
def _ask(self, run: Any) -> int:
|
|
103
|
+
permitted = list(run.permitted)
|
|
104
|
+
# The chooser sees the steps and the run's own figures. It does not see
|
|
105
|
+
# the workflow, because neither does this process.
|
|
106
|
+
try:
|
|
107
|
+
index = int(self.choose(permitted, dict(getattr(run, "figures", {}) or {})))
|
|
108
|
+
except (TypeError, ValueError) as exc:
|
|
109
|
+
raise RefusedChoice(f"chooser did not return a step index: {exc}") from exc
|
|
110
|
+
|
|
111
|
+
if not 0 <= index < len(permitted):
|
|
112
|
+
raise RefusedChoice(
|
|
113
|
+
f"chooser returned index {index}, outside the {len(permitted)} "
|
|
114
|
+
"steps that were permitted. It may only pick from what was offered."
|
|
115
|
+
)
|
|
116
|
+
logger.info("agent chose %s", permitted[index]["op"])
|
|
117
|
+
return index
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Client for a workflow server: execute steps locally, report figures.
|
|
2
|
+
|
|
3
|
+
from alphaengine.client import connect
|
|
4
|
+
|
|
5
|
+
session = connect("https://...", api_key="...")
|
|
6
|
+
run = session.open("validate_study", data=prices, backtest_fn=mine, grid={...})
|
|
7
|
+
run.drive()
|
|
8
|
+
|
|
9
|
+
THE SPLIT
|
|
10
|
+
The server holds the workflow: which steps, in what order, under what
|
|
11
|
+
conditions, and what stops a run. This side holds your data and executes
|
|
12
|
+
what it is asked to, one operation at a time.
|
|
13
|
+
|
|
14
|
+
Neither half moves. Your prices stay on your machine and the sequencing
|
|
15
|
+
stays on the server, which is what makes the arrangement stable rather than
|
|
16
|
+
a standoff between two parties who each want the other's part.
|
|
17
|
+
|
|
18
|
+
EVERYTHING ELSE IN THIS PACKAGE WORKS WITHOUT ANY OF THIS
|
|
19
|
+
`sweep`, the core maths and the study artifact need no server, no account
|
|
20
|
+
and no network. This subpackage is additive. If it is not for you, importing
|
|
21
|
+
the top level never touches it.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from .executor import StepExecutor, UnsupportedOp
|
|
27
|
+
from .session import Offline, Run, ServerError, Session, connect, connect_or_offline
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"connect",
|
|
31
|
+
"connect_or_offline",
|
|
32
|
+
"Session",
|
|
33
|
+
"Run",
|
|
34
|
+
"StepExecutor",
|
|
35
|
+
"UnsupportedOp",
|
|
36
|
+
"Offline",
|
|
37
|
+
"ServerError",
|
|
38
|
+
]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""The step executor: runs one operation locally and returns figures.
|
|
2
|
+
|
|
3
|
+
THE HALF OF THE SPLIT THAT LIVES ON YOUR MACHINE. A workflow server tells this
|
|
4
|
+
executor what to do; the executor does it against data that never leaves, and
|
|
5
|
+
returns derived figures. Your prices, your returns and your parameter grid stay
|
|
6
|
+
where they are.
|
|
7
|
+
|
|
8
|
+
WHAT THIS DELIBERATELY DOES NOT CONTAIN
|
|
9
|
+
No graph, no router, no notion of what comes next. It executes what it is
|
|
10
|
+
handed and reports. If you find yourself wanting to add "and then usually
|
|
11
|
+
we..." to this file, that belongs to whoever is orchestrating, not here.
|
|
12
|
+
The absence is the design: an executor that knew the sequence would be a
|
|
13
|
+
second, worse copy of the server's job, and the two would drift.
|
|
14
|
+
|
|
15
|
+
WHAT COMES BACK IS FIGURES, NEVER SERIES
|
|
16
|
+
Every handler returns scalars and small structures. A server that asked for
|
|
17
|
+
a return series would be refused, and none does: the whole arrangement rests
|
|
18
|
+
on the data staying put, so the client enforces its own half rather than
|
|
19
|
+
trusting the other end.
|
|
20
|
+
|
|
21
|
+
THE WORKSPACE
|
|
22
|
+
Some operations read what an earlier one produced: a deflated Sharpe needs
|
|
23
|
+
the sweep's trial matrix. That intermediate stays HERE, in memory, keyed by
|
|
24
|
+
op name. The server sees the figure, never the matrix.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
from ..core import (
|
|
37
|
+
compute_var_cvar,
|
|
38
|
+
deflated_sharpe,
|
|
39
|
+
min_track_record_length,
|
|
40
|
+
pbo_cscv,
|
|
41
|
+
performance_report,
|
|
42
|
+
technical_features,
|
|
43
|
+
)
|
|
44
|
+
from ..sweep import sweep as run_sweep
|
|
45
|
+
|
|
46
|
+
__all__ = ["StepExecutor", "UnsupportedOp", "MAX_FIGURE_LIST", "Handler"]
|
|
47
|
+
|
|
48
|
+
# A figures payload: derived values, never a series. `Workspace` holds the
|
|
49
|
+
# intermediates that stay on this machine (the trial matrix, most importantly).
|
|
50
|
+
Figures = dict[str, Any]
|
|
51
|
+
Workspace = dict[str, Any]
|
|
52
|
+
Handler = Callable[[Figures, Workspace], Figures]
|
|
53
|
+
|
|
54
|
+
# Mirrors the server's guard. Enforced on our side too, because "the data never
|
|
55
|
+
# leaves" should not depend on the other end remembering to check.
|
|
56
|
+
MAX_FIGURE_LIST = 64
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class UnsupportedOp(LookupError):
|
|
60
|
+
"""This build cannot execute that operation.
|
|
61
|
+
|
|
62
|
+
Raised rather than silently skipped. A skipped step reports success on work
|
|
63
|
+
that never happened, and the figure it did not produce becomes a gap
|
|
64
|
+
somewhere downstream that nobody can trace back to here.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _hash(data: Any) -> str:
|
|
69
|
+
try:
|
|
70
|
+
arr = np.asarray(data, dtype=float)
|
|
71
|
+
return hashlib.sha256(np.ascontiguousarray(arr).tobytes()).hexdigest()[:16]
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
blob = json.dumps(data, sort_keys=True, default=str).encode()
|
|
74
|
+
return hashlib.sha256(blob).hexdigest()[:16]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _n_obs(data: Any) -> int:
|
|
78
|
+
try:
|
|
79
|
+
return int(np.asarray(data, dtype=float).shape[0])
|
|
80
|
+
except (TypeError, ValueError, IndexError):
|
|
81
|
+
try:
|
|
82
|
+
return len(data)
|
|
83
|
+
except TypeError:
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _guard(figures: dict[str, Any]) -> dict[str, Any]:
|
|
88
|
+
"""Refuse to send anything series-shaped, whatever it is called."""
|
|
89
|
+
|
|
90
|
+
def walk(node: Any, path: str) -> None:
|
|
91
|
+
if isinstance(node, dict):
|
|
92
|
+
for k, v in node.items():
|
|
93
|
+
walk(v, f"{path}.{k}")
|
|
94
|
+
elif isinstance(node, (list, tuple)):
|
|
95
|
+
if len(node) > MAX_FIGURE_LIST:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"refusing to send {path}: {len(node)} elements is a series, "
|
|
98
|
+
"not a figure. Your data stays on your machine."
|
|
99
|
+
)
|
|
100
|
+
for i, v in enumerate(node):
|
|
101
|
+
walk(v, f"{path}[{i}]")
|
|
102
|
+
|
|
103
|
+
walk(figures, "figures")
|
|
104
|
+
return figures
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class StepExecutor:
|
|
108
|
+
"""Executes ops against local data.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
data: whatever your steps operate on. Passed to your backtest function
|
|
112
|
+
untouched and never transmitted.
|
|
113
|
+
backtest_fn: your own backtest, for `compute.sweep`. We orchestrate and
|
|
114
|
+
measure; you simulate.
|
|
115
|
+
handlers: extra or overriding op handlers, as {op: callable(params,
|
|
116
|
+
workspace) -> figures}. This is the extension point for `data.*` and
|
|
117
|
+
`record.*` ops that only your environment can answer.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
*,
|
|
123
|
+
data: Any = None,
|
|
124
|
+
backtest_fn: Callable[..., Any] | None = None,
|
|
125
|
+
handlers: dict[str, Handler] | None = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
self.data = data
|
|
128
|
+
self.backtest_fn = backtest_fn
|
|
129
|
+
self.workspace: dict[str, Any] = {}
|
|
130
|
+
self._handlers: dict[str, Handler] = {
|
|
131
|
+
"data.resolve": self._resolve,
|
|
132
|
+
"data.describe": self._resolve,
|
|
133
|
+
"compute.sweep": self._sweep,
|
|
134
|
+
"compute.deflated_sharpe": self._deflated,
|
|
135
|
+
"compute.pbo_cscv": self._pbo,
|
|
136
|
+
"compute.min_track_record_length": self._mintrl,
|
|
137
|
+
"compute.performance_report": self._performance,
|
|
138
|
+
"compute.compute_var_cvar": self._var,
|
|
139
|
+
"compute.technical_features": self._technical,
|
|
140
|
+
}
|
|
141
|
+
if handlers:
|
|
142
|
+
self._handlers.update(handlers)
|
|
143
|
+
|
|
144
|
+
def supports(self, op: str) -> bool:
|
|
145
|
+
return op in self._handlers
|
|
146
|
+
|
|
147
|
+
def execute(self, op: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
148
|
+
handler = self._handlers.get(op)
|
|
149
|
+
if handler is None:
|
|
150
|
+
raise UnsupportedOp(
|
|
151
|
+
f"{op!r} is not executable by this build. Supply a handler for it, or upgrade alphaengine."
|
|
152
|
+
)
|
|
153
|
+
return _guard(handler(dict(params or {}), self.workspace))
|
|
154
|
+
|
|
155
|
+
# ── handlers ───────────────────────────────────────────────────────────
|
|
156
|
+
def _resolve(self, params: Figures, ws: Workspace) -> Figures:
|
|
157
|
+
"""Identify the data without disclosing it.
|
|
158
|
+
|
|
159
|
+
A content hash rather than a name: a label can be changed to escape a
|
|
160
|
+
history and an array cannot, so this is what makes a run reproducible
|
|
161
|
+
without the series ever crossing.
|
|
162
|
+
"""
|
|
163
|
+
return {
|
|
164
|
+
"ref": params.get("ref") or "local",
|
|
165
|
+
"hash": _hash(self.data),
|
|
166
|
+
"n_obs": _n_obs(self.data),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
def _sweep(self, params: Figures, ws: Workspace) -> Figures:
|
|
170
|
+
if self.backtest_fn is None:
|
|
171
|
+
raise UnsupportedOp(
|
|
172
|
+
"compute.sweep needs your backtest function. Pass backtest_fn to "
|
|
173
|
+
"StepExecutor: we orchestrate and measure, you simulate."
|
|
174
|
+
)
|
|
175
|
+
result = run_sweep(self.backtest_fn, params.get("grid") or {}, data=self.data)
|
|
176
|
+
ws["sweep"] = result # the trial matrix stays here
|
|
177
|
+
|
|
178
|
+
surface = result.surface()
|
|
179
|
+
return {
|
|
180
|
+
"n_trials": result.n_trials,
|
|
181
|
+
"n_trials_source": "derived_from_grid",
|
|
182
|
+
"data_hash": result.data_hash,
|
|
183
|
+
"shape": surface["shape"],
|
|
184
|
+
"share_within_20pct_of_best": surface["share_within_20pct_of_best"],
|
|
185
|
+
"best_sharpe": surface["best_sharpe"],
|
|
186
|
+
"n_ok": surface["n_ok"],
|
|
187
|
+
"n_failed": surface["n_failed"],
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
def _best_column(self, ws: Workspace) -> list[float]:
|
|
191
|
+
result = ws.get("sweep")
|
|
192
|
+
if result is None:
|
|
193
|
+
raise UnsupportedOp("no sweep in this run's workspace; that figure has nothing to read.")
|
|
194
|
+
column: list[float] = result.matrix[:, result.best.index].tolist()
|
|
195
|
+
return column
|
|
196
|
+
|
|
197
|
+
def _deflated(self, params: Figures, ws: Workspace) -> Figures:
|
|
198
|
+
result = ws.get("sweep")
|
|
199
|
+
# The trial count comes from the sweep that actually ran, not from the
|
|
200
|
+
# server's parameter. A count supplied from outside is a count somebody
|
|
201
|
+
# could flatter.
|
|
202
|
+
n_trials = result.n_trials if result is not None else int(params.get("n_trials") or 1)
|
|
203
|
+
out = deflated_sharpe(self._best_column(ws), n_trials=n_trials)
|
|
204
|
+
return {
|
|
205
|
+
"deflated_sharpe": out.get("deflated_sharpe"),
|
|
206
|
+
"psr_vs_zero": out.get("psr_vs_zero"),
|
|
207
|
+
"sr0_expected_max": out.get("sr0_expected_max"),
|
|
208
|
+
"verdict": out.get("verdict"),
|
|
209
|
+
"n_trials": n_trials,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
def _pbo(self, params: Figures, ws: Workspace) -> Figures:
|
|
213
|
+
result = ws.get("sweep")
|
|
214
|
+
if result is None or result.matrix.shape[1] < 2:
|
|
215
|
+
# Honest rather than a number that looks like an answer: one
|
|
216
|
+
# configuration means the choice among configurations was not a
|
|
217
|
+
# choice.
|
|
218
|
+
return {"pbo": None, "note": "needs at least two configurations"}
|
|
219
|
+
out = pbo_cscv(result.matrix.tolist())
|
|
220
|
+
return {"pbo": out.get("pbo"), "verdict": out.get("verdict"), "n_configs": out.get("n_configs")}
|
|
221
|
+
|
|
222
|
+
def _mintrl(self, params: Figures, ws: Workspace) -> Figures:
|
|
223
|
+
return dict(min_track_record_length(self._best_column(ws)))
|
|
224
|
+
|
|
225
|
+
def _performance(self, params: Figures, ws: Workspace) -> Figures:
|
|
226
|
+
return dict(performance_report(self._best_column(ws)))
|
|
227
|
+
|
|
228
|
+
def _var(self, params: Figures, ws: Workspace) -> Figures:
|
|
229
|
+
out = compute_var_cvar(self._best_column(ws))
|
|
230
|
+
return {"confidence": out.get("confidence"), "parametric": out.get("parametric")}
|
|
231
|
+
|
|
232
|
+
def _technical(self, params: Figures, ws: Workspace) -> Figures:
|
|
233
|
+
out = technical_features(self.data, **params)
|
|
234
|
+
# Only the scalar readings travel; any embedded series is dropped rather
|
|
235
|
+
# than transmitted.
|
|
236
|
+
return {k: v for k, v in out.items() if not isinstance(v, (list, tuple))}
|