alphaengine 0.4.0__tar.gz → 0.6.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 (71) hide show
  1. alphaengine-0.6.0/AGENTS.md +55 -0
  2. {alphaengine-0.4.0 → alphaengine-0.6.0}/PKG-INFO +18 -6
  3. {alphaengine-0.4.0 → alphaengine-0.6.0}/README.md +15 -4
  4. {alphaengine-0.4.0 → alphaengine-0.6.0}/pyproject.toml +2 -1
  5. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/_version.py +28 -1
  6. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/auth.py +16 -1
  7. alphaengine-0.6.0/src/alphaengine/book.py +158 -0
  8. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/cli.py +537 -257
  9. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/client/agent.py +41 -6
  10. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/client/executor.py +201 -1
  11. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/client/session.py +37 -56
  12. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/commands.py +101 -11
  13. alphaengine-0.6.0/src/alphaengine/complete.py +71 -0
  14. alphaengine-0.6.0/src/alphaengine/connectors/__init__.py +75 -0
  15. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/technical.py +6 -4
  16. alphaengine-0.6.0/src/alphaengine/core/walkforward.py +139 -0
  17. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/demo.py +8 -0
  18. alphaengine-0.6.0/src/alphaengine/events.py +195 -0
  19. alphaengine-0.6.0/src/alphaengine/model.py +500 -0
  20. alphaengine-0.6.0/src/alphaengine/repl.py +75 -0
  21. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/sweep/runner.py +37 -18
  22. {alphaengine-0.4.0 → alphaengine-0.6.0}/tests/test_agent.py +8 -0
  23. alphaengine-0.6.0/tests/test_book.py +40 -0
  24. {alphaengine-0.4.0 → alphaengine-0.6.0}/tests/test_cli.py +27 -0
  25. alphaengine-0.6.0/tests/test_events.py +57 -0
  26. alphaengine-0.6.0/tests/test_executor_ops.py +62 -0
  27. alphaengine-0.6.0/tests/test_model.py +96 -0
  28. alphaengine-0.4.0/src/alphaengine/model.py +0 -248
  29. {alphaengine-0.4.0 → alphaengine-0.6.0}/.github/workflows/ci.yml +0 -0
  30. {alphaengine-0.4.0 → alphaengine-0.6.0}/.github/workflows/publish.yml +0 -0
  31. {alphaengine-0.4.0 → alphaengine-0.6.0}/.gitignore +0 -0
  32. {alphaengine-0.4.0 → alphaengine-0.6.0}/LICENSE +0 -0
  33. {alphaengine-0.4.0 → alphaengine-0.6.0}/SECURITY.md +0 -0
  34. {alphaengine-0.4.0 → alphaengine-0.6.0}/scripts/gen_docs.py +0 -0
  35. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/__init__.py +0 -0
  36. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/__main__.py +0 -0
  37. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/agent/__init__.py +0 -0
  38. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/agent/answer.py +0 -0
  39. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/agent/driver.py +0 -0
  40. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/client/__init__.py +0 -0
  41. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/__init__.py +0 -0
  42. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/backtest.py +0 -0
  43. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/factors.py +0 -0
  44. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/pairs.py +0 -0
  45. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/performance.py +0 -0
  46. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/profile.py +0 -0
  47. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/risk.py +0 -0
  48. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/screen.py +0 -0
  49. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/series_shapes.py +0 -0
  50. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/signals.py +0 -0
  51. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/stress.py +0 -0
  52. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/core/validation.py +0 -0
  53. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/demo_book.py +0 -0
  54. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/demo_returns.py +0 -0
  55. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/demo_signal.py +0 -0
  56. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/demo_universe.py +0 -0
  57. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/loaders.py +0 -0
  58. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/py.typed +0 -0
  59. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/study/__init__.py +0 -0
  60. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/study/report.py +0 -0
  61. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/study/schema.py +0 -0
  62. {alphaengine-0.4.0 → alphaengine-0.6.0}/src/alphaengine/sweep/__init__.py +0 -0
  63. {alphaengine-0.4.0 → alphaengine-0.6.0}/tests/test_answer.py +0 -0
  64. {alphaengine-0.4.0 → alphaengine-0.6.0}/tests/test_client.py +0 -0
  65. {alphaengine-0.4.0 → alphaengine-0.6.0}/tests/test_commands.py +0 -0
  66. {alphaengine-0.4.0 → alphaengine-0.6.0}/tests/test_goldens.py +0 -0
  67. {alphaengine-0.4.0 → alphaengine-0.6.0}/tests/test_loaders.py +0 -0
  68. {alphaengine-0.4.0 → alphaengine-0.6.0}/tests/test_screen.py +0 -0
  69. {alphaengine-0.4.0 → alphaengine-0.6.0}/tests/test_signals.py +0 -0
  70. {alphaengine-0.4.0 → alphaengine-0.6.0}/tests/test_smoke.py +0 -0
  71. {alphaengine-0.4.0 → alphaengine-0.6.0}/tests/test_sweep.py +0 -0
@@ -0,0 +1,55 @@
1
+ # AGENTS.md
2
+
3
+ ## Cursor Cloud specific instructions
4
+
5
+ AlphaEngine is a pure-Python CLI + library (`alphaengine`) for validated quant
6
+ research. There is no GUI; all interaction is via the terminal or
7
+ `import alphaengine`.
8
+
9
+ ### Environment
10
+
11
+ - Dependencies live in `.venv` (Python 3.12). Use `.venv/bin/<tool>` or
12
+ `source .venv/bin/activate`. The console script is `.venv/bin/alphaengine`.
13
+ - Installed editable with `dev` and `factors` extras so statsmodels is present.
14
+
15
+ ### Lint / test / build / run
16
+
17
+ - Lint: `.venv/bin/python -m ruff check src tests` and
18
+ `.venv/bin/python -m ruff format --check src tests`
19
+ - Types: `.venv/bin/python -m mypy src`
20
+ - Tests: `.venv/bin/python -m pytest` and
21
+ `.venv/bin/python -m pytest tests -m golden -q`
22
+ - Docs guard: `.venv/bin/python scripts/gen_docs.py --check`
23
+ (regenerate with `--write`). A benign `SyntaxWarning: invalid escape sequence '\|'`
24
+ is pre-existing.
25
+ - Offline app: `.venv/bin/alphaengine demo`
26
+
27
+ ### Non-obvious gotchas
28
+
29
+ - **Three rungs.** `demo` and the importable library are fully offline. Workflow
30
+ CLI verbs (`diagnose`, `screen`, …, `run <workflow>`) need a portal
31
+ `ae_live_` key (`QUANTOS_API_KEY`). Plain-English mode also needs a **model**
32
+ key. Any OpenAI-compatible key works: Anthropic, OpenAI (`OPENAI_BASE_URL` for
33
+ gateways), Gemini, Groq, OpenRouter, Azure, or
34
+ `ALPHAENGINE_API_KEY`+`ALPHAENGINE_BASE_URL`. `alphaengine models` lists what
35
+ this machine can actually use. Keys may persist in
36
+ `~/.config/alphaengine/credentials.json` (mode 0600) if the user says yes at
37
+ `key <provider>`; env always wins. LLM keys are never sent to QuantOS.
38
+ - **A stop / `marginal` verdict exits 0.** Only a step that could not execute
39
+ exits non-zero. Unauthenticated workflow calls exit 2.
40
+ - **Data never leaves.** Executor and study-report guards refuse lists longer
41
+ than 512 by length. Model telemetry (choices, why, post-guard answers, prompt
42
+ hashes) may POST to `/api/harness/runs/{id}/events` when logged in; a 404 is
43
+ swallowed and the same allowlist is always written to
44
+ `~/.local/share/alphaengine/runs/<id>.jsonl`. Never send prompts, series, or
45
+ keys. `alphaengine trace` reads the local dump.
46
+ - **Goldens are a public contract.** Do not "fix" a golden to land a speedup;
47
+ that is a version bump (`_version.py`).
48
+ - **New compute ops** (`backtest`, `score_backtest`, `cpcv`, `factors`, `pairs`,
49
+ `cointegrated_pairs`, `walk_forward`, `book_overlap`) are on the executor.
50
+ The portal must offer them in a workflow graph or they sit unused; the CLI
51
+ remains useful without them.
52
+ - `sweep(..., jobs=N)` defaults to 1. Greater than 1 is opt-in and must keep
53
+ trial index identity, including failures.
54
+ - The `connectors` extra is lazy: parquet via `pyarrow`, HTTP via `httpx` to a
55
+ URL the caller names. `import alphaengine` must not import httpx.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: alphaengine
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -26,6 +26,7 @@ Requires-Dist: numpy>=1.24
26
26
  Requires-Dist: scipy>=1.10
27
27
  Provides-Extra: agents
28
28
  Requires-Dist: anthropic>=0.40; extra == 'agents'
29
+ Requires-Dist: openai>=1.0; extra == 'agents'
29
30
  Provides-Extra: connectors
30
31
  Requires-Dist: httpx>=0.27; extra == 'connectors'
31
32
  Requires-Dist: pyarrow>=15.0; extra == 'connectors'
@@ -150,9 +151,14 @@ distinction is the whole of the data boundary below.
150
151
  |---|---|---|
151
152
  | `demo` | run the built-in example offline, with no account and no data | shell + session |
152
153
  | `runs [--limit N]` | your own week: what ran, what it decided, what it filed | shell + session |
154
+ | `gaps` | what your record says is UNANSWERED, and what closes each one | shell + session |
155
+ | `tonight [--budget N]` | what would run unattended tonight, without running any of it | shell + session |
153
156
  | `workflows` | what the server offers, what each needs, and which reproduce | shell + session |
154
- | `key [quantos \| anthropic \| openai]` | enter a credential now, or see which rungs are unlocked | session |
157
+ | `key [quantos \| anthropic \| openai \| gemini \| groq \| azure \| openrouter \| gateway]` | enter a credential now, or see which rungs are unlocked | session |
155
158
  | `commands [verb]` | this directory, or one command in full | shell + session |
159
+ | `models` | which model providers this machine can actually use | shell + session |
160
+ | `model [<provider[:name]>]` | pin the model for this session, or show the pin | session |
161
+ | `trace [run_id]` | local model/run events for a run, hashed prompts only | shell + session |
156
162
 
157
163
  ### Do the work
158
164
 
@@ -174,8 +180,9 @@ distinction is the whole of the data boundary below.
174
180
 
175
181
  | Command | Does | Where |
176
182
  |---|---|---|
183
+ | `book [<name> \| status]` | show or load sleeves on the multi-strategy book | session |
177
184
  | `universe <name>` | load a universe you registered in the portal, with its closes | session |
178
- | `data <file.csv>` | load a local CSV without leaving the session | session |
185
+ | `data <file>` | load a local CSV or parquet without leaving the session | session |
179
186
  | `project <module>` | load `data` and `backtest_fn` from a module of yours | session |
180
187
 
181
188
  ### Session
@@ -205,12 +212,12 @@ distinction is the whole of the data boundary below.
205
212
  | `--data FILE` | a local CSV: wide, long, or a single series |
206
213
  | `--universe NAME` | a universe registered in the portal, with its stored closes |
207
214
  | `--symbol TICKER` | one name out of a loaded universe; its closes become the return series |
208
- | `--sleeve NAME` | the sleeve this run belongs to, and the budget it is bound by |
209
- | `--thesis ID` | a draft thesis to propose this run's shortlist as a sleeve for |
210
215
  | `--label TEXT` | what to call the artifact this run produces |
211
216
  | `--input K=V` | a workflow input; repeatable |
212
217
  | `--quiet` | only the result, no step narration |
218
+ | `--stream` | print the answer once the citation guard has passed |
213
219
  | `--limit N` | how many rows to show (default 25) |
220
+ | `--budget N` | how many runs a night is worth (default 3) |
214
221
 
215
222
  ### Examples
216
223
 
@@ -226,11 +233,16 @@ alphaengine monitor
226
233
  alphaengine demo
227
234
  alphaengine runs
228
235
  alphaengine runs --limit 50
236
+ alphaengine gaps
237
+ alphaengine tonight
238
+ alphaengine tonight --budget 5
229
239
  alphaengine workflows
230
240
  alphaengine commands
231
241
  alphaengine run screen_universe --universe sp500
232
242
  alphaengine run size_position --data returns.csv
233
243
  alphaengine run validate_study --project research.momentum
244
+ alphaengine models
245
+ alphaengine trace
234
246
  alphaengine logout
235
247
  alphaengine version
236
248
  ```
@@ -109,9 +109,14 @@ distinction is the whole of the data boundary below.
109
109
  |---|---|---|
110
110
  | `demo` | run the built-in example offline, with no account and no data | shell + session |
111
111
  | `runs [--limit N]` | your own week: what ran, what it decided, what it filed | shell + session |
112
+ | `gaps` | what your record says is UNANSWERED, and what closes each one | shell + session |
113
+ | `tonight [--budget N]` | what would run unattended tonight, without running any of it | shell + session |
112
114
  | `workflows` | what the server offers, what each needs, and which reproduce | shell + session |
113
- | `key [quantos \| anthropic \| openai]` | enter a credential now, or see which rungs are unlocked | session |
115
+ | `key [quantos \| anthropic \| openai \| gemini \| groq \| azure \| openrouter \| gateway]` | enter a credential now, or see which rungs are unlocked | session |
114
116
  | `commands [verb]` | this directory, or one command in full | shell + session |
117
+ | `models` | which model providers this machine can actually use | shell + session |
118
+ | `model [<provider[:name]>]` | pin the model for this session, or show the pin | session |
119
+ | `trace [run_id]` | local model/run events for a run, hashed prompts only | shell + session |
115
120
 
116
121
  ### Do the work
117
122
 
@@ -133,8 +138,9 @@ distinction is the whole of the data boundary below.
133
138
 
134
139
  | Command | Does | Where |
135
140
  |---|---|---|
141
+ | `book [<name> \| status]` | show or load sleeves on the multi-strategy book | session |
136
142
  | `universe <name>` | load a universe you registered in the portal, with its closes | session |
137
- | `data <file.csv>` | load a local CSV without leaving the session | session |
143
+ | `data <file>` | load a local CSV or parquet without leaving the session | session |
138
144
  | `project <module>` | load `data` and `backtest_fn` from a module of yours | session |
139
145
 
140
146
  ### Session
@@ -164,12 +170,12 @@ distinction is the whole of the data boundary below.
164
170
  | `--data FILE` | a local CSV: wide, long, or a single series |
165
171
  | `--universe NAME` | a universe registered in the portal, with its stored closes |
166
172
  | `--symbol TICKER` | one name out of a loaded universe; its closes become the return series |
167
- | `--sleeve NAME` | the sleeve this run belongs to, and the budget it is bound by |
168
- | `--thesis ID` | a draft thesis to propose this run's shortlist as a sleeve for |
169
173
  | `--label TEXT` | what to call the artifact this run produces |
170
174
  | `--input K=V` | a workflow input; repeatable |
171
175
  | `--quiet` | only the result, no step narration |
176
+ | `--stream` | print the answer once the citation guard has passed |
172
177
  | `--limit N` | how many rows to show (default 25) |
178
+ | `--budget N` | how many runs a night is worth (default 3) |
173
179
 
174
180
  ### Examples
175
181
 
@@ -185,11 +191,16 @@ alphaengine monitor
185
191
  alphaengine demo
186
192
  alphaengine runs
187
193
  alphaengine runs --limit 50
194
+ alphaengine gaps
195
+ alphaengine tonight
196
+ alphaengine tonight --budget 5
188
197
  alphaengine workflows
189
198
  alphaengine commands
190
199
  alphaengine run screen_universe --universe sp500
191
200
  alphaengine run size_position --data returns.csv
192
201
  alphaengine run validate_study --project research.momentum
202
+ alphaengine models
203
+ alphaengine trace
193
204
  alphaengine logout
194
205
  alphaengine version
195
206
  ```
@@ -68,6 +68,7 @@ factors = [
68
68
  # Sharpe should not need an LLM dependency to get one.
69
69
  agents = [
70
70
  "anthropic>=0.40",
71
+ "openai>=1.0",
71
72
  ]
72
73
  # Pulling data from a provider the customer already pays for.
73
74
  connectors = [
@@ -178,5 +179,5 @@ ignore_missing_imports = true
178
179
  # import is an error, so this is the difference between a build that passes on a
179
180
  # machine that happens to have them and one that passes anywhere.
180
181
  [[tool.mypy.overrides]]
181
- module = ["scipy.*", "statsmodels.*"]
182
+ module = ["scipy.*", "statsmodels.*", "pyarrow.*", "httpx"]
182
183
  ignore_missing_imports = true
@@ -29,4 +29,31 @@ is what a changed figure costs. The API may still move underneath it.
29
29
  #
30
30
  # Not one of those is a bug fix, and every one changes what a saved study
31
31
  # reproduces. That costs the MINOR position while the leading digit is 0.
32
- __version__ = "0.4.0"
32
+ #
33
+ # 0.5.0 IS THE RULE READ THE OTHER WAY: no computed value moved, and the SURFACE
34
+ # broke. `--sleeve` and `--thesis` are removed along with `Session.sleeves()`,
35
+ # `.theses()` and `.propose_sleeve()`. Their three routes were part of the thesis
36
+ # object model, which came out of the platform with the agent desk it was built
37
+ # around, so the flags could only have failed at the END of a run — after the
38
+ # work, naming a server error rather than a missing feature.
39
+ #
40
+ # A removed flag is a breaking change even though every figure is byte-identical,
41
+ # and while the leading digit is 0 the MINOR position carries that too.
42
+ #
43
+ # Added in the same release, and the reason the removal is not a loss: `gaps` and
44
+ # `tonight`. The record already knew which workflow closes each open gap and
45
+ # nothing traversed those edges; these two read that derivation, so the terminal
46
+ # can now answer "what have I NOT tried" and "what will run while I sleep".
47
+ #
48
+ # 0.6.0 IS THE SAME RULE READ AS A FEATURE RELEASE. No computed value moved.
49
+ # The artefact grew a research OS around the maths that already shipped: any
50
+ # BYOK model (Anthropic, OpenAI-compatible, Gemini, Groq, OpenRouter, Azure,
51
+ # a private gateway), the workflow ops that were library-only (`backtest`,
52
+ # CPCV, factors, pairs, walk-forward, book overlap), allowlisted traces that
53
+ # stay on disk when the portal has no route, a session with history and slash
54
+ # verbs, and a screen that paints the parameter surface it is judging.
55
+ #
56
+ # A new published wheel is a new thing people `pip install`. While the leading
57
+ # digit is 0 the MINOR position carries that, the same way 0.5.0 carried a
58
+ # surface change that left every figure byte-identical.
59
+ __version__ = "0.6.0"
@@ -55,7 +55,22 @@ __all__ = ["config_path", "load_stored", "save_key", "clear_stored", "apply_stor
55
55
  #: `QUANTOS_API_KEY` is ours. The provider keys are here because a user who has
56
56
  #: said "sign me in" means all of it, and leaving one of the three to be pasted
57
57
  #: every session defeats the point.
58
- MANAGED = ("QUANTOS_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY")
58
+ MANAGED = (
59
+ "QUANTOS_API_KEY",
60
+ "ANTHROPIC_API_KEY",
61
+ "OPENAI_API_KEY",
62
+ "GEMINI_API_KEY",
63
+ "GOOGLE_API_KEY",
64
+ "GROQ_API_KEY",
65
+ "OPENROUTER_API_KEY",
66
+ "AZURE_OPENAI_API_KEY",
67
+ "AZURE_OPENAI_ENDPOINT",
68
+ "ALPHAENGINE_API_KEY",
69
+ "ALPHAENGINE_BASE_URL",
70
+ "ALPHAENGINE_MODEL",
71
+ "ALPHAENGINE_PROVIDER",
72
+ "OPENAI_BASE_URL",
73
+ )
59
74
 
60
75
 
61
76
  def config_path() -> Path:
@@ -0,0 +1,158 @@
1
+ """A multi-strategy book: named sleeves, figures, never an OMS.
2
+
3
+ WHAT THIS IS
4
+ The overlap/size/monitor questions, asked of a STACK of return series rather
5
+ than one candidate vs one book series. A desk that runs six sleeves needs to
6
+ know how a seventh sits on the whole stack, and whether any sleeve is
7
+ already outside its lines.
8
+
9
+ WHAT THIS IS NOT
10
+ Orders, shares, brokers, or target weights that become fills. `save_signals`
11
+ still files ticker/rank/score/weight. A book here is a research object.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+
21
+ from .core.risk import compute_var_cvar
22
+ from .core.stress import overlap_stats
23
+ from .core.validation import min_track_record_length
24
+
25
+ __all__ = ["Book", "MAX_SLEEVES"]
26
+
27
+ #: A correlation matrix over 2000 names is a data export. Cap the book so the
28
+ #: figures that leave it stay figures.
29
+ MAX_SLEEVES = 16
30
+
31
+
32
+ def _as_returns(values: Any) -> list[float]:
33
+ if isinstance(values, dict) and "returns" in values:
34
+ values = values["returns"]
35
+ return [float(x) for x in list(values)]
36
+
37
+
38
+ @dataclass
39
+ class Book:
40
+ """Named sleeves of return series, plus optional current weights."""
41
+
42
+ sleeves: dict[str, list[float]] = field(default_factory=dict)
43
+ weights: dict[str, float] = field(default_factory=dict)
44
+
45
+ def add(self, name: str, returns: Any, *, weight: float | None = None) -> None:
46
+ if len(self.sleeves) >= MAX_SLEEVES and name not in self.sleeves:
47
+ raise ValueError(f"a book holds at most {MAX_SLEEVES} sleeves; remove one first")
48
+ self.sleeves[str(name)] = _as_returns(returns)
49
+ if weight is not None:
50
+ self.weights[str(name)] = float(weight)
51
+
52
+ def drop(self, name: str) -> None:
53
+ self.sleeves.pop(name, None)
54
+ self.weights.pop(name, None)
55
+
56
+ @property
57
+ def names(self) -> list[str]:
58
+ return list(self.sleeves)
59
+
60
+ def combined_returns(self) -> list[float]:
61
+ """Equal-weight (or stated-weight) stack, aligned from the most recent end."""
62
+ if not self.sleeves:
63
+ return []
64
+ series = {k: np.asarray(v, dtype=float) for k, v in self.sleeves.items()}
65
+ n = min(int(a.size) for a in series.values())
66
+ if n <= 0:
67
+ return []
68
+ stacked = np.column_stack([a[-n:] for a in series.values()])
69
+ w = np.array([self.weights.get(k, 1.0) for k in series], dtype=float)
70
+ if float(w.sum()) == 0:
71
+ w = np.ones_like(w)
72
+ w = w / w.sum()
73
+ return [float(x) for x in (stacked @ w).tolist()]
74
+
75
+ def overlap_matrix(
76
+ self, candidate: Any | None = None, *, candidate_name: str = "candidate"
77
+ ) -> dict[str, Any]:
78
+ """Pairwise correlation/beta, bounded. Candidate optional."""
79
+ names = list(self.sleeves)
80
+ series = dict(self.sleeves)
81
+ if candidate is not None:
82
+ series[candidate_name] = _as_returns(candidate)
83
+ names = [candidate_name, *names]
84
+ names = names[:MAX_SLEEVES]
85
+ pairs: list[dict[str, Any]] = []
86
+ for i, a in enumerate(names):
87
+ for b in names[i + 1 :]:
88
+ stats = overlap_stats(series[a], series[b])
89
+ pairs.append(
90
+ {
91
+ "a": a,
92
+ "b": b,
93
+ "correlation": stats.get("correlation"),
94
+ "beta_to_book": stats.get("beta_to_book"),
95
+ "n_obs": stats.get("n_obs"),
96
+ }
97
+ )
98
+ return {
99
+ "n_sleeves": len(names),
100
+ "names": names,
101
+ "pairs": pairs,
102
+ }
103
+
104
+ def residual_weight(self, name: str, *, target: float = 1.0) -> dict[str, Any]:
105
+ """How much of `target` is left after the sleeves already held.
106
+
107
+ Refuses rather than sizing small when the candidate record is shorter
108
+ than MinTRL — a small weight is still a claim.
109
+ """
110
+ if name not in self.sleeves:
111
+ raise KeyError(name)
112
+ returns = self.sleeves[name]
113
+ mintrl = min_track_record_length(returns)
114
+ if mintrl.get("error") or not mintrl.get("sufficient"):
115
+ return {
116
+ "name": name,
117
+ "refused": True,
118
+ "reason": "record_too_short",
119
+ "min_track_record_length": mintrl,
120
+ }
121
+ held = sum(self.weights.get(k, 0.0) for k in self.sleeves if k != name)
122
+ residual = max(0.0, float(target) - held)
123
+ risk = compute_var_cvar(returns)
124
+ return {
125
+ "name": name,
126
+ "refused": False,
127
+ "held_weight": round(held, 6),
128
+ "residual_weight": round(residual, 6),
129
+ "cvar": risk.get("cvar"),
130
+ "min_track_record_length": mintrl,
131
+ }
132
+
133
+ def monitor(self) -> dict[str, Any]:
134
+ """Per-sleeve status. Nothing checked never reads as all-clear."""
135
+ rows: list[dict[str, Any]] = []
136
+ for name, returns in self.sleeves.items():
137
+ if len(returns) < 2:
138
+ rows.append({"name": name, "status": "unchecked", "n_obs": len(returns)})
139
+ continue
140
+ risk = compute_var_cvar(returns)
141
+ mintrl = min_track_record_length(returns)
142
+ status = "ok" if mintrl.get("sufficient") else "undetermined"
143
+ rows.append(
144
+ {
145
+ "name": name,
146
+ "status": status,
147
+ "n_obs": len(returns),
148
+ "cvar": risk.get("cvar"),
149
+ "sufficient": mintrl.get("sufficient"),
150
+ }
151
+ )
152
+ if not rows or any(r["status"] == "unchecked" for r in rows):
153
+ overall = "unchecked"
154
+ elif any(r["status"] != "ok" for r in rows):
155
+ overall = "undetermined"
156
+ else:
157
+ overall = "ok"
158
+ return {"overall": overall, "sleeves": rows, "n_sleeves": len(rows)}