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.
Files changed (35) hide show
  1. alphaengine-0.1.2/CHANGELOG.md +108 -0
  2. {alphaengine-0.1.0 → alphaengine-0.1.2}/PKG-INFO +28 -1
  3. {alphaengine-0.1.0 → alphaengine-0.1.2}/README.md +27 -0
  4. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/__init__.py +20 -0
  5. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/_version.py +1 -1
  6. alphaengine-0.1.2/src/alphaengine/agent/__init__.py +16 -0
  7. alphaengine-0.1.2/src/alphaengine/agent/driver.py +117 -0
  8. alphaengine-0.1.2/src/alphaengine/client/__init__.py +38 -0
  9. alphaengine-0.1.2/src/alphaengine/client/executor.py +236 -0
  10. alphaengine-0.1.2/src/alphaengine/client/session.py +237 -0
  11. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/study/__init__.py +21 -1
  12. alphaengine-0.1.2/src/alphaengine/study/report.py +180 -0
  13. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/study/schema.py +11 -0
  14. alphaengine-0.1.2/tests/test_client.py +295 -0
  15. {alphaengine-0.1.0 → alphaengine-0.1.2}/tests/test_smoke.py +29 -0
  16. alphaengine-0.1.0/CHANGELOG.md +0 -46
  17. {alphaengine-0.1.0 → alphaengine-0.1.2}/.github/workflows/ci.yml +0 -0
  18. {alphaengine-0.1.0 → alphaengine-0.1.2}/.github/workflows/publish.yml +0 -0
  19. {alphaengine-0.1.0 → alphaengine-0.1.2}/.gitignore +0 -0
  20. {alphaengine-0.1.0 → alphaengine-0.1.2}/LICENSE +0 -0
  21. {alphaengine-0.1.0 → alphaengine-0.1.2}/SECURITY.md +0 -0
  22. {alphaengine-0.1.0 → alphaengine-0.1.2}/pyproject.toml +0 -0
  23. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/__init__.py +0 -0
  24. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/backtest.py +0 -0
  25. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/factors.py +0 -0
  26. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/pairs.py +0 -0
  27. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/performance.py +0 -0
  28. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/risk.py +0 -0
  29. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/technical.py +0 -0
  30. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/core/validation.py +0 -0
  31. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/py.typed +0 -0
  32. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/sweep/__init__.py +0 -0
  33. {alphaengine-0.1.0 → alphaengine-0.1.2}/src/alphaengine/sweep/runner.py +0 -0
  34. {alphaengine-0.1.0 → alphaengine-0.1.2}/tests/test_goldens.py +0 -0
  35. {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.0
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}")
@@ -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.0"
15
+ __version__ = "0.1.2"
@@ -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))}